2f5c70ac29
- serializeProjectToSchema now writes master volume_db and mastering gui_open_path/name - deserialize restores masterVolume + masteringSettings.gui_open_path/name - track vst_gui_open_path/name fallback to live state (not just _persistFxGui*) - MasteringModal/FXRackModal reopen effects react to isOpen + restored gui path - openMasterVstGui/openVstGui store gui name for reopen - bridge: /preset capture endpoint + RenderFxJob.captureState for GUI knob state
623 lines
28 KiB
C++
623 lines
28 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);
|
|
|
|
namespace {
|
|
|
|
// --- 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) {
|
|
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.
|
|
std::vector<unsigned char> rgb((size_t)w * h * 3);
|
|
for (int i = 0; i < w * h; ++i) {
|
|
rgb[(size_t)i * 3 + 0] = px[(size_t)i * 4 + 2];
|
|
rgb[(size_t)i * 3 + 1] = px[(size_t)i * 4 + 1];
|
|
rgb[(size_t)i * 3 + 2] = px[(size_t)i * 4 + 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 fallback ------------------------------------------------------
|
|
// Some editors (JUCE, iZotope) reject synthetic PostMessage input entirely and
|
|
// only react to real hardware input while their window is visible. Probe: if 2
|
|
// consecutive clicks do not change the frame, switch this plugin to real-input
|
|
// mode — briefly move the window topmost to the work-area corner, inject real
|
|
// mouse events at the mapped screen position, then restore it off-screen.
|
|
std::atomic<bool> g_realInput{false}; // plugin needs real hardware input
|
|
// probe: hit (frame changed) resets g_miss; 2 consecutive misses switch
|
|
// to real input. A one-off "hit" (e.g. initial settle repaint) must NOT
|
|
// lock us onto PostMessage, so there is no permanent "detected" state.
|
|
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
|
|
int g_miss = 0; // consecutive no-response PostMessage clicks
|
|
uint64_t g_preClickHash = 0;
|
|
// 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)
|
|
|
|
uint64_t frameHash(HWND hwnd) {
|
|
std::vector<unsigned char> jpg;
|
|
if (!captureHwndToJpeg(hwnd, jpg, 70)) return 0;
|
|
uint64_t h = 1469598103934665603ull; // FNV-1a 64
|
|
for (unsigned char b : jpg) { h ^= b; h *= 1099511628211ull; }
|
|
return h;
|
|
}
|
|
|
|
void danceStart(HWND hwnd, HWND target, int x, int y) {
|
|
// Anchored mode: place the host window's client area exactly over the embed
|
|
// panel rect (borderless + topmost) so real mouse events hit the plugin
|
|
// without moving the cursor, then ask the pump thread to size the plugin
|
|
// view to the panel. No park deadline -- the window stays until /close.
|
|
// Re-runs on every click so a scrolled/resized panel re-anchors.
|
|
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_TOPMOST, g_embedRect.left, g_embedRect.top,
|
|
cw, ch, SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
|
fxGuiRequestResize(hwnd, cw, ch);
|
|
Sleep(60); // let the compositor repaint so the editor sees itself active
|
|
g_dance = true;
|
|
g_anchored = true;
|
|
g_parkUntil = 0; // anchored: stay until /close
|
|
return;
|
|
}
|
|
if (g_dance) return;
|
|
// Dat window ngay tai vi tri cursor (dang nam tren panel embed trong DAW)
|
|
// chu khong nhay ra goc man hinh: shift window sao cho client point (x,y)
|
|
// cua editor trung voi screen point dang click -> GUI xuat hien dung cho
|
|
// panel, khong con floating ngoai cua so DAW.
|
|
POINT pt = { x, y };
|
|
ClientToScreen(target, &pt); // screen cua diem click (window con 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);
|
|
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;
|
|
|
|
// Probe while still on PostMessage: a changed frame means PostMessage
|
|
// responds (reset miss); two consecutive no-response clicks mean the
|
|
// editor rejects synthetic input — switch to real hardware input.
|
|
if (!g_realInput) {
|
|
if (g_embedRect.right > g_embedRect.left) {
|
|
// Anchored mode exists for plugins that need real input; the window
|
|
// appears exactly over the panel, so probing PostMessage first only
|
|
// wastes clicks.
|
|
g_realInput = true;
|
|
} else if (isDown) {
|
|
g_preClickHash = frameHash(hwnd);
|
|
} else if (isUp && g_preClickHash) {
|
|
Sleep(250);
|
|
uint64_t nowHash = frameHash(hwnd);
|
|
bool resp = nowHash != g_preClickHash;
|
|
if (resp) {
|
|
g_miss = 0; // PostMessage responded — keep it
|
|
} else if (++g_miss >= 2) {
|
|
g_realInput = true; // synthetic input rejected — go real
|
|
g_miss = 0;
|
|
}
|
|
g_preClickHash = 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);
|
|
std::vector<unsigned char> jpg;
|
|
if (captureHwndToJpeg(hwnd, jpg, 70))
|
|
sendResponse(c, 200, "image/jpeg", jpg.data(), jpg.size());
|
|
else
|
|
sendResponse(c, 500, "application/json", "{\"error\":\"capture failed\"}", 25);
|
|
} 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);
|
|
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);
|
|
const bool same = cur.left == g_embedRect.left && cur.top == g_embedRect.top &&
|
|
cur.right - cur.left == cw && cur.bottom - cur.top == ch;
|
|
// Frontend re-POSTs /show every ~500ms to re-anchor on scroll;
|
|
// skip both when nothing moved so the plugin view is not
|
|
// re-laid-out in a loop. Re-show after /hide regardless.
|
|
if (!same || !IsWindowVisible(hwnd))
|
|
SetWindowPos(hwnd, HWND_TOPMOST, g_embedRect.left, g_embedRect.top,
|
|
cw, ch, SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
|
if (cur.right - cur.left != cw || cur.bottom - cur.top != ch)
|
|
fxGuiRequestResize(hwnd, cw, ch);
|
|
g_dance = true;
|
|
g_anchored = true;
|
|
g_parkUntil = 0;
|
|
} else {
|
|
ShowWindow(hwnd, SW_SHOW);
|
|
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0,
|
|
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
|
}
|
|
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
|
|
} else if (req.path == "/preset") {
|
|
std::string b64;
|
|
fxGuiRequestCapture(hwnd, b64, 1500);
|
|
const std::string body = "{\"preset_b64\":\"" + b64 + "\"}";
|
|
sendResponse(c, 200, "application/json", body.data(), (int)body.size());
|
|
} 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
|