Files
SonicForgeStudio/native_bridge/src/FxGuiServer.cpp
T

646 lines
30 KiB
C++

// native_bridge/src/FxGuiServer.cpp
// Embedded VST GUI server (Phase 0 of PLAN_MASTERBUS_VST_GUI_EMBED.md):
// `daw_vst_bridge --fx-gui <job.json>` loads ONE VST3 effect plugin, attaches
// its native editor to an off-screen host window, and serves the window as
// JPEG frames over a tiny HTTP server so the web frontend can embed it in a
// panel (frame-capture + input relay, VNC-style). Windows only.
//
// Routes:
// GET /ping -> {"ok":true}
// GET /frame -> image/jpeg (BitBlt, fallback PrintWindow)
// POST /input -> {type:"mousedown|mousemove|mouseup|wheel|keydown|keyup",
// x,y,button,deltaY,keyCode} -> PostMessage to HWND
// POST /close -> detach + exit 0
// GET /preset -> {"preset_b64":"..."} current plugin state
//
// The HTTP server runs on a worker thread; the calling (STA) thread runs the
// message pump — VST3 editors need COM apartment + pump on the same thread.
// The listen port is random and printed to stdout as `SF_FXGUI_PORT=<port>`.
#ifdef _WIN32
#include <winsock2.h>
#include <windows.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
#include <cstdio>
#include <jpeglib.h>
#include <atomic>
#include <cstring>
#include <cstdint>
#include <functional>
#include <iostream>
#include <string>
#include <thread>
#include <vector>
// sheredom/json.h (public domain) — vendored copy; SDK copy as fallback.
#if __has_include("sheredom_json.h")
#include "sheredom_json.h"
#elif __has_include("vst3sdk/public.sdk/source/vst/moduleinfo/json.h")
#include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h"
#endif
// Defined in RenderFxJob.cpp (global scope); declared here outside the
// anonymous namespace so both TUs see the same symbol.
extern void fxGuiRequestResize(HWND hwnd, int w, int h);
extern void fxGuiRequestCapture(void* hwnd, std::string& outB64, int timeoutMs);
// Tell RenderFxJob's SHM audio feeder which realtime session is live (the
// frontend pushes the session name; "" = session stopped/restarted).
extern void fxGuiSetShm(const std::string& name);
// Set by RenderFxJob once the VST editor attached to the host window; /frame
// refuses (503) until then so the webview never shows the empty host window.
extern std::atomic<bool> g_fxGuiEditorReady;
namespace {
// Last good captured JPEG: /frame falls back to it while the editor window is
// blank/repainting (avoids flashing the panel white/black). Only touched by
// the single HTTP worker thread — no lock needed.
std::vector<unsigned char> g_lastGood;
// --- tiny JSON helpers (same API as RenderFxJob.cpp) ------------------------
const json_object_element_s* member(const json_object_s* o, const char* key) {
for (const json_object_element_s* e = o ? o->start : nullptr; e; e = e->next)
if (e->name && e->name->string && std::strcmp(e->name->string, key) == 0)
return e;
return nullptr;
}
const json_value_s* memberValue(const json_object_s* o, const char* key) {
const json_object_element_s* m = member(o, key);
return m ? m->value : nullptr;
}
std::string memberString(const json_object_s* o, const char* key, const std::string& def) {
const json_value_s* v = memberValue(o, key);
if (v && v->type == json_type_string) {
const auto* s = static_cast<const json_string_s*>(v->payload);
return std::string(s->string, s->string_size);
}
return def;
}
int64_t memberInt(const json_object_s* o, const char* key, int64_t def) {
const json_value_s* v = memberValue(o, key);
if (v && v->type == json_type_number)
return (int64_t)std::atof(static_cast<const json_number_s*>(v->payload)->number);
return def;
}
// --- HTTP plumbing -----------------------------------------------------------
struct HttpReq {
std::string method;
std::string path;
std::string body;
};
// Read one request (headers up to \r\n\r\n + Content-Length body). Returns
// false on socket error or malformed request line.
bool readRequest(SOCKET c, HttpReq& out) {
std::string raw;
char buf[4096];
while (raw.find("\r\n\r\n") == std::string::npos) {
if (raw.size() > 16384) return false;
int n = recv(c, buf, (int)sizeof(buf), 0);
if (n <= 0) return false;
raw.append(buf, (size_t)n);
}
const size_t headEnd = raw.find("\r\n\r\n");
const std::string head = raw.substr(0, headEnd);
const size_t sp1 = head.find(' ');
const size_t sp2 = head.find(' ', sp1 + 1);
if (sp1 == std::string::npos || sp2 == std::string::npos) return false;
out.method = head.substr(0, sp1);
out.path = head.substr(sp1 + 1, sp2 - sp1 - 1);
// strip query string: frontend cache-busts /frame?t=<ms>
const size_t q = out.path.find('?');
if (q != std::string::npos) out.path.resize(q);
long contentLength = 0;
size_t pos = 0;
while ((pos = head.find("\r\n", pos)) != std::string::npos) {
const size_t lineEnd = head.find("\r\n", pos + 2);
const std::string line = head.substr(pos + 2, lineEnd == std::string::npos ? std::string::npos : lineEnd - pos - 2);
pos = lineEnd == std::string::npos ? std::string::npos : lineEnd;
if (line.rfind("Content-Length:", 0) == 0 || line.rfind("content-length:", 0) == 0) {
contentLength = std::atol(line.c_str() + 15);
break;
}
}
if (contentLength > 0) {
while (raw.size() < headEnd + 4 + (size_t)contentLength) {
int n = recv(c, buf, (int)sizeof(buf), 0);
if (n <= 0) return false;
raw.append(buf, (size_t)n);
}
out.body = raw.substr(headEnd + 4, (size_t)contentLength);
}
return true;
}
void sendResponse(SOCKET c, int code, const char* contentType,
const void* data, size_t len) {
char hdr[512];
std::snprintf(hdr, sizeof(hdr),
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\n"
"Content-Length: %zu\r\nAccess-Control-Allow-Origin: *\r\n"
"Connection: close\r\n\r\n",
code, code == 200 ? "OK" : "Error", contentType, len);
send(c, hdr, (int)std::strlen(hdr), 0);
if (len > 0) send(c, (const char*)data, (int)len, 0);
}
// CORS preflight reply: the embed page (origin 127.0.0.1:8000) POSTs JSON to
// this server (a different port), so the browser sends an OPTIONS request
// first. Without this handler the preflight fell into the /input JSON parser
// and failed with 400, so the embedded GUI looked frozen: frames streamed but
// clicks never reached the plugin.
void sendOptions(SOCKET c) {
const char* r =
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n"
"Content-Length: 0\r\nConnection: close\r\n\r\n";
send(c, r, (int)std::strlen(r), 0);
}
// --- frame capture -----------------------------------------------------------
// Capture the window client rect twice — BitBlt (GDI view) and PrintWindow
// with PW_RENDERFULLCONTENT (DWM composition, includes DirectX/GPU surfaces
// that GDI cannot see). PrintWindow is preferred: it returns the live composed
// frame, while the GDI BitBlt view is a stale first paint for GPU-rendered
// editors (JUCE/FAST Compressor) and never updates. BitBlt stays as fallback.
// Returns JPEG.
bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality, float* outAvg = nullptr) {
RECT rc;
if (!GetClientRect(hwnd, &rc)) return false;
const int w = rc.right - rc.left;
const int h = rc.bottom - rc.top;
if (w <= 0 || h <= 0) return false;
HDC win = GetDC(hwnd);
HDC mem = CreateCompatibleDC(win);
HBITMAP bmp = CreateCompatibleBitmap(win, w, h);
HGDIOBJ oldBmp = SelectObject(mem, bmp);
std::vector<unsigned char> pxB((size_t)w * h * 4), pxP((size_t)w * h * 4);
auto grabInto = [&](unsigned char* p) -> bool {
BITMAPINFO bi = {};
bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bi.bmiHeader.biWidth = w;
bi.bmiHeader.biHeight = -h; // top-down
bi.bmiHeader.biPlanes = 1;
bi.bmiHeader.biBitCount = 32;
bi.bmiHeader.biCompression = BI_RGB;
return GetDIBits(mem, bmp, 0, h, p, &bi, DIB_RGB_COLORS) == h;
};
bool okB = BitBlt(mem, 0, 0, w, h, win, 0, 0, SRCCOPY | CAPTUREBLT) != 0 && grabInto(pxB.data());
// PW_RENDERFULLCONTENT asks DWM to compose the window (includes child
// windows + DirectX surfaces) — the only way to see GPU-rendered editors.
bool okP = PrintWindow(hwnd, mem, PW_RENDERFULLCONTENT) != 0 && grabInto(pxP.data());
// Prefer the PrintWindow frame: PW_RENDERFULLCONTENT composes the live
// window (child + GPU surfaces). The GDI BitBlt view is a stale first paint
// for GPU-rendered editors (JUCE/FAST Compressor) and never updates, which
// made the panel look frozen. BitBlt stays only as a fallback.
unsigned char* px = nullptr;
if (okP) px = pxP.data();
else if (okB) px = pxB.data();
SelectObject(mem, oldBmp);
DeleteObject(bmp);
DeleteDC(mem);
ReleaseDC(hwnd, win);
if (!px) return false;
// BGRA (GDI) -> RGB, then JPEG. Accumulate mean luma (0..1) so the
// caller can reject blank frames (host window not painted yet).
std::vector<unsigned char> rgb((size_t)w * h * 3);
double lumaSum = 0;
for (int i = 0; i < w * h; ++i) {
const unsigned char b = px[(size_t)i * 4 + 0];
const unsigned char g = px[(size_t)i * 4 + 1];
const unsigned char r = px[(size_t)i * 4 + 2];
rgb[(size_t)i * 3 + 0] = r;
rgb[(size_t)i * 3 + 1] = g;
rgb[(size_t)i * 3 + 2] = b;
lumaSum += (double)r + g + b;
}
if (outAvg) *outAvg = (float)(lumaSum / ((double)w * h * 3 * 255.0));
jpeg_compress_struct cinfo = {};
jpeg_error_mgr jerr;
cinfo.err = jpeg_std_error(&jerr);
jpeg_create_compress(&cinfo);
unsigned char* memBuf = nullptr;
unsigned long memSize = 0;
jpeg_mem_dest(&cinfo, &memBuf, &memSize);
cinfo.image_width = (JDIMENSION)w;
cinfo.image_height = (JDIMENSION)h;
cinfo.input_components = 3;
cinfo.in_color_space = JCS_RGB;
jpeg_set_defaults(&cinfo);
jpeg_set_quality(&cinfo, quality, TRUE);
jpeg_start_compress(&cinfo, TRUE);
JSAMPROW rows[1];
rows[0] = rgb.data();
while (cinfo.next_scanline < cinfo.image_height) {
jpeg_write_scanlines(&cinfo, rows, 1);
rows[0] += (size_t)w * 3;
}
jpeg_finish_compress(&cinfo);
if (memBuf && memSize > 0) out.assign(memBuf, memBuf + memSize);
std::free(memBuf);
jpeg_destroy_compress(&cinfo);
return !out.empty();
}
// --- real-input --------------------------------------------------------------
// JUCE/iZotope editors reject synthetic PostMessage input entirely and only
// react to real hardware input while their window is visible, so real input
// is always on: on click the plugin window dances over the embed panel rect
// (anchored) and real mouse events are injected at the mapped screen pos.
// Probe-based fallback (4bb0db2 era) removed: frame-hash probing was defeated
// by animated GUIs (meter repaint) — the frame always changes, g_miss never
// reached 2, PostMessage stayed on and every click silently died.
std::atomic<bool> g_realInput{true}; // always real hardware input
std::atomic<bool> g_dance{false}; // window temporarily on-screen/topmost
std::atomic<DWORD> g_parkUntil{0}; // safety restore deadline (GetTickCount)
POINT g_savedCursor = {0, 0}; // cursor to restore after the dance
// Anchored mode: the frontend sends the embed panel's screen rect (physical
// px) with every /input, so the real-input dance places the plugin window
// exactly over the panel -- cursor never moves, no park/restore churn. Zero
// rect = legacy cursor-relative mode.
RECT g_embedRect = {0, 0, 0, 0};
bool g_anchored = false; // window anchored over panel (stays until /close)
// Lift the window above the foreground app without making it TOPMOST (the
// foreground-lock would otherwise swallow real clicks). Attaching to the
// foreground thread is required to steal foreground from another process.
static void bringToTop(HWND hwnd) {
DWORD fgTid = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
DWORD myTid = GetCurrentThreadId();
if (fgTid && fgTid != myTid) AttachThreadInput(fgTid, myTid, TRUE);
SetForegroundWindow(hwnd);
BringWindowToTop(hwnd);
if (fgTid && fgTid != myTid) AttachThreadInput(fgTid, myTid, FALSE);
}
void danceStart(HWND hwnd, HWND target, int x, int y) {
// Real-input fallback (only entered when synthetic PostMessage was probed
// as rejected — iZotope/JUCE editors): hardware input needs the plugin
// window visible under the cursor. Anchored mode: place it exactly over
// the embed panel rect (borderless, WS_EX_NOACTIVATE so the DAW keeps
// focus, HWND_TOP NOT TOPMOST so it never floats over other apps — the
// "floating always-on-top" bug that got real input disabled in 4bb0db2).
if (g_embedRect.right > g_embedRect.left && g_embedRect.bottom > g_embedRect.top) {
const int cw = g_embedRect.right - g_embedRect.left;
const int ch = g_embedRect.bottom - g_embedRect.top;
LONG_PTR style = GetWindowLongPtrA(hwnd, GWL_STYLE);
if (style & (WS_CAPTION | WS_THICKFRAME | WS_SYSMENU |
WS_MINIMIZEBOX | WS_MAXIMIZEBOX)) {
style &= ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU |
WS_MINIMIZEBOX | WS_MAXIMIZEBOX);
SetWindowLongPtrA(hwnd, GWL_STYLE, style);
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER |
SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
SetWindowPos(hwnd, HWND_TOP, g_embedRect.left, g_embedRect.top,
cw, ch, SWP_NOACTIVATE | SWP_SHOWWINDOW);
bringToTop(hwnd); // plugin must sit above the foreground app (e.g. browser) or real clicks land there
fxGuiRequestResize(hwnd, cw, ch);
Sleep(60); // let the compositor repaint so the editor sees itself active
g_dance = true;
g_anchored = true; // stays over the panel until /close (re-anchored by /show)
g_parkUntil = 0;
return;
}
if (g_dance) return;
// No rect known: shift the window so the clicked point sits under the
// cursor (cursor already over the embed panel inside the DAW).
POINT pt = { x, y };
ClientToScreen(target, &pt); // screen pos of the clicked point (child offscreen)
POINT cur;
GetCursorPos(&cur);
RECT wr = {0};
GetWindowRect(hwnd, &wr);
const int nx = wr.left + (cur.x - pt.x);
const int ny = wr.top + (cur.y - pt.y);
GetCursorPos(&g_savedCursor);
SetWindowPos(hwnd, HWND_TOP, nx, ny, 0, 0,
SWP_NOSIZE | SWP_SHOWWINDOW | SWP_NOACTIVATE);
Sleep(60); // let the compositor repaint so the editor sees itself active
g_dance = true;
g_parkUntil = GetTickCount() + 3000;
}
void danceEnd(HWND hwnd) {
if (!g_dance) return;
if (g_anchored) return; // anchored: stays until /close
SetWindowPos(hwnd, HWND_NOTOPMOST, -20000, -20000, 0, 0,
SWP_NOSIZE | SWP_NOACTIVATE);
SetCursorPos(g_savedCursor.x, g_savedCursor.y);
g_dance = false;
g_parkUntil = 0;
}
void realClick(int sx, int sy, bool down, int button) {
SetCursorPos(sx, sy);
Sleep(10);
DWORD flags;
if (button == 2) flags = down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP;
else if (button == 1) flags = down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP;
else flags = down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP;
mouse_event(flags, 0, 0, 0, 0);
}
// --- input relay -------------------------------------------------------------
void relayInput(HWND hwnd, const json_object_s* o) {
// The plugin editor is a CHILD window created by IPlugView::attached().
// DefWindowProc on the top-level host does NOT forward mouse/keyboard to
// it, so post directly to the view (fallback to the host window).
HWND target = FindWindowExA(hwnd, nullptr, nullptr, nullptr);
if (!target) target = hwnd;
const std::string type = memberString(o, "type", "");
const int x = (int)memberInt(o, "x", 0);
const int y = (int)memberInt(o, "y", 0);
// Anchored rect from the embed panel (screen coords, physical px).
const json_value_s* rv = memberValue(o, "rect");
if (rv && rv->type == json_type_object) {
const json_object_s* ro = static_cast<const json_object_s*>(rv->payload);
const LONG rw = (LONG)memberInt(ro, "w", 0);
const LONG rh = (LONG)memberInt(ro, "h", 0);
if (rw > 0 && rh > 0) {
g_embedRect.left = (LONG)memberInt(ro, "x", 0);
g_embedRect.top = (LONG)memberInt(ro, "y", 0);
g_embedRect.right = g_embedRect.left + rw;
g_embedRect.bottom = g_embedRect.top + rh;
} else {
g_embedRect = {0, 0, 0, 0}; // empty rect -> back to cursor mode
}
}
const int button = (int)memberInt(o, "button", 0);
const int buttons = (int)memberInt(o, "buttons", 0); // DOM e.buttons bitmask
const int deltaY = (int)memberInt(o, "deltaY", 0);
const WPARAM keyCode = (WPARAM)memberInt(o, "keyCode", 0);
const LPARAM lp = MAKELPARAM(x, y);
// DOM buttons: 1=left 2=right 4=middle -> MK_LBUTTON/MK_RBUTTON/MK_MBUTTON.
WPARAM down = 0;
if (buttons & 1) down |= MK_LBUTTON;
if (buttons & 2) down |= MK_RBUTTON;
if (buttons & 4) down |= MK_MBUTTON;
const bool isDown = type == "mousedown";
const bool isUp = type == "mouseup";
// Mouseleave reports -1,-1: finish the drag without clicking a bogus point.
const bool outside = x < 0 || y < 0;
if (g_realInput && (isDown || isUp || type == "mousemove" || type == "wheel")) {
if (isDown) {
danceStart(hwnd, target, x, y);
POINT pt = { x, y };
if (!outside) ClientToScreen(target, &pt);
realClick(pt.x, pt.y, true, button);
} else if (type == "mousemove") {
// Drag only: moving the cursor on plain hover fights the webview
// (the plugin window sits under the pointer while anchored, so
// hover reaches it naturally — no SetCursorPos needed).
if (g_dance && buttons != 0) {
POINT pt = { x, y };
ClientToScreen(target, &pt);
SetCursorPos(pt.x, pt.y);
g_parkUntil = GetTickCount() + 3000; // keep up during drag
}
} else if (isUp) {
if (g_dance) {
if (!outside) {
POINT pt = { x, y };
ClientToScreen(target, &pt);
realClick(pt.x, pt.y, false, button);
}
danceEnd(hwnd);
}
} else if (type == "wheel") {
danceStart(hwnd, target, x, y);
POINT pt = { x, y };
if (!outside) ClientToScreen(target, &pt);
SetCursorPos(pt.x, pt.y);
mouse_event(MOUSEEVENTF_WHEEL, 0, 0, (DWORD)(-(int64_t)deltaY * 120), 0);
g_parkUntil = GetTickCount() + 600; // stay one beat, then restore
}
return;
}
if (type == "mousemove") {
PostMessageA(target, WM_MOUSEMOVE, down, lp); // button state => drag works
} else if (type == "mousedown") {
UINT m = button == 2 ? WM_RBUTTONDOWN : button == 1 ? WM_MBUTTONDOWN : WM_LBUTTONDOWN;
WPARAM w = button == 2 ? MK_RBUTTON : button == 1 ? MK_MBUTTON : MK_LBUTTON;
PostMessageA(target, m, w, lp);
PostMessageA(target, WM_MOUSEMOVE, w, lp); // plugins tracking absolute pos
SetFocus(target);
} else if (type == "mouseup") {
UINT m = button == 2 ? WM_RBUTTONUP : button == 1 ? WM_MBUTTONUP : WM_LBUTTONUP;
PostMessageA(target, m, 0, lp);
} else if (type == "wheel") {
// WM_MOUSEWHEEL lParam carries SCREEN coords of the cursor.
POINT pt = { x, y };
ClientToScreen(target, &pt);
PostMessageA(target, WM_MOUSEWHEEL, MAKEWPARAM(0, (short)(-(int64_t)deltaY * 120)), MAKELPARAM(pt.x, pt.y));
} else if (type == "keydown") {
SetFocus(target);
PostMessageA(target, WM_KEYDOWN, keyCode, 0);
} else if (type == "keyup") {
PostMessageA(target, WM_KEYUP, keyCode, 0);
}
}
// --- HTTP server loop (worker thread) ---------------------------------------
void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { std::cerr << "[FxGuiServer] WSAStartup failed" << std::endl; return; }
SOCKET lsock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (lsock == INVALID_SOCKET) { WSACleanup(); return; }
sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0; // random free port
if (bind(lsock, (sockaddr*)&addr, sizeof(addr)) != 0 || listen(lsock, 4) != 0) {
closesocket(lsock);
WSACleanup();
std::cerr << "[FxGuiServer] bind/listen failed" << std::endl;
return;
}
sockaddr_in bound = {};
int blen = sizeof(bound);
getsockname(lsock, (sockaddr*)&bound, &blen);
std::cout << "SF_FXGUI_PORT=" << ntohs(bound.sin_port) << std::endl;
std::cout.flush();
while (!stop.load()) {
fd_set rf;
FD_ZERO(&rf);
FD_SET(lsock, &rf);
timeval tv = { 0, 100000 }; // 100ms poll so /close can break out
int sel = select(0, &rf, nullptr, nullptr, &tv);
if (sel <= 0) continue;
SOCKET c = accept(lsock, nullptr, nullptr);
if (c == INVALID_SOCKET) continue;
HttpReq req;
if (!readRequest(c, req)) { closesocket(c); continue; }
if (req.method == "OPTIONS") {
sendOptions(c);
} else if (req.path == "/ping") {
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/frame") {
// Real-input wheel parks the window on-screen; restore when the
// 600ms beat expires (checked here since /frame polls ~100ms).
if (g_dance && g_parkUntil &&
(int32_t)(GetTickCount() - g_parkUntil) >= 0)
danceEnd(hwnd);
if (!g_fxGuiEditorReady.load()) {
// VST still loading / editor attaching (pump thread): refuse
// rather than stream the empty host window — the webview used
// to show that blank frame as a white flash on open.
sendResponse(c, 503, "application/json", "{\"error\":\"loading\"}", 19);
} else {
std::vector<unsigned char> jpg;
float avg = -1.0f;
if (captureHwndToJpeg(hwnd, jpg, 70, &avg) &&
avg >= 0.02f && avg <= 0.98f) {
g_lastGood = jpg; // single HTTP worker thread — no lock
sendResponse(c, 200, "image/jpeg", jpg.data(), jpg.size());
} else if (!g_lastGood.empty()) {
// Blank/transient frame (window still repainting) — keep
// the last good frame so the panel never flashes blank.
sendResponse(c, 200, "image/jpeg", g_lastGood.data(), g_lastGood.size());
} else {
sendResponse(c, 503, "application/json", "{\"error\":\"loading\"}", 19);
}
}
} else if (req.path == "/input") {
json_parse_result_s pres = {};
json_value_s* root = json_parse_ex(req.body.data(), req.body.size(),
json_parse_flags_default, nullptr, nullptr, &pres);
if (root && root->type == json_type_object) {
relayInput(hwnd, static_cast<const json_object_s*>(root->payload));
std::free(root);
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else {
if (root) std::free(root);
sendResponse(c, 400, "application/json", "{\"error\":\"bad json\"}", 20);
}
} else if (req.path == "/hide") {
// Demote first: without it the next SW_SHOW would keep the
// window topmost and it would float over other apps.
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
ShowWindow(hwnd, SW_HIDE);
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/show") {
// Optional body {rect:{x,y,w,h}} (screen px) — anchored placement
// over the embed panel; without a rect just show + top (never
// topmost — the GUI must not stay above other apps).
json_parse_result_s spres = {};
json_value_s* sroot = json_parse_ex(req.body.data(), req.body.size(),
json_parse_flags_default, nullptr, nullptr, &spres);
bool rectOk = false;
if (sroot && sroot->type == json_type_object) {
const json_object_s* so = static_cast<const json_object_s*>(sroot->payload);
const json_value_s* srv = memberValue(so, "rect");
if (srv && srv->type == json_type_object) {
const json_object_s* sro = static_cast<const json_object_s*>(srv->payload);
const LONG srw = (LONG)memberInt(sro, "w", 0);
const LONG srh = (LONG)memberInt(sro, "h", 0);
if (srw > 0 && srh > 0) {
g_embedRect.left = (LONG)memberInt(sro, "x", 0);
g_embedRect.top = (LONG)memberInt(sro, "y", 0);
g_embedRect.right = g_embedRect.left + srw;
g_embedRect.bottom = g_embedRect.top + srh;
rectOk = true;
}
}
}
if (sroot) std::free(sroot);
// Embedded mode: the native window NEVER comes on-screen — it stays
// parked off-screen at -20000,-20000 (GDI capture + synthetic input
// work regardless of position). The panel rect sizes the host
// window + plugin view so the capture frame matches the embed panel
// (layout bug); /hide hid the window, so re-show it off-screen so
// /frame keeps capturing.
if (rectOk) {
const int cw = g_embedRect.right - g_embedRect.left;
const int ch = g_embedRect.bottom - g_embedRect.top;
RECT cur = {};
GetWindowRect(hwnd, &cur);
// Skip when already the right size: frontend re-POSTs /show
// every ~500ms; re-laying-out the plugin view every tick is
// wasteful (WM_FXGUI_RESIZE -> checkSizeConstraint/onSize).
if (cur.right - cur.left != cw || cur.bottom - cur.top != ch)
fxGuiRequestResize(hwnd, cw, ch);
}
if (!IsWindowVisible(hwnd))
SetWindowPos(hwnd, HWND_NOTOPMOST, -20000, -20000, 0, 0,
SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
// Dance/anchor state is owned by the input relay — /show must not
// yank the window off-screen (real input is always on).
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/preset") {
std::string b64;
fxGuiRequestCapture(hwnd, b64, 500); // 1500 blocked the HTTP worker, stalling /input (clicks)
const std::string body = "{\"preset_b64\":\"" + b64 + "\"}";
sendResponse(c, 200, "application/json", body.data(), (int)body.size());
} else if (req.path == "/shm") {
json_parse_result_s spres = {};
json_value_s* sroot = json_parse_ex(req.body.data(), req.body.size(),
json_parse_flags_default, nullptr, nullptr, &spres);
if (sroot && sroot->type == json_type_object) {
const json_object_s* so = static_cast<const json_object_s*>(sroot->payload);
fxGuiSetShm(memberString(so, "shm", ""));
std::free(sroot);
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else {
if (sroot) std::free(sroot);
sendResponse(c, 400, "application/json", "{\"error\":\"bad json\"}", 20);
}
} else if (req.path == "/close") {
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
stop.store(true);
PostMessageA(hwnd, WM_CLOSE, 0, 0); // pump exits -> process returns
} else {
sendResponse(c, 404, "application/json", "{\"error\":\"not found\"}", 22);
}
closesocket(c);
}
closesocket(lsock);
WSACleanup();
}
} // namespace
// Runs the embedded GUI server: starts the HTTP worker thread, then pumps
// messages on this (STA) thread until the host window is destroyed (user
// closed it or POST /close). Returns 0.
int fxGuiServerLoop(HWND hwnd, const std::function<void()>& onStarted) {
std::atomic<bool> stop{false};
std::thread http(runHttpServer, hwnd, std::ref(stop));
// onStarted runs on the pump (STA) thread AFTER the HTTP server is up and
// the port printed — used to defer the slow plugin load/attach in server
// mode so the caller (plugins.py) doesn't block on VST load.
if (onStarted) onStarted();
MSG msg;
while (IsWindow(hwnd)) {
BOOL r = GetMessageA(&msg, nullptr, 0, 0);
if (r <= 0) break;
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
stop.store(true);
if (http.joinable()) http.join();
return 0;
}
#endif // _WIN32