DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
DSParkWebViewEditor.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
67#if defined(DSPARK_PLUGIN_VST3_INCLUDED) || defined(DSPARK_PLUGIN_CLAP_INCLUDED) \
68 || defined(DSPARK_PLUGIN_AU_INCLUDED)
69#error "Include plugin/webview/DSParkWebViewEditor.h BEFORE the plugin format headers"
70#endif
71
72#define DSPARK_PLUGIN_WEBVIEW 1
73
74#include "../DSParkPlugin.h"
75
76#include <atomic>
77#include <cmath>
78#include <cstdarg>
79#include <cstdio>
80#include <cstdlib>
81#include <cstring>
82#include <string>
83
84// -- platform backend selection --------------------------------------------------
85//
86// DSPARK_WEBVIEW_BACKEND: 1 = WebView2 via vendored webview.h (Windows),
87// 2 = WKWebView via the Objective-C runtime (macOS),
88// 3 = WebKitGTK via dlopen + GtkPlug/XEmbed (Linux/X11),
89// 0 = stub (editor reports unavailable).
90
91#if defined(_WIN32) && !defined(DSPARK_NO_EXCEPTIONS) \
92 && (defined(__cpp_exceptions) || (defined(_CPPUNWIND) && _CPPUNWIND))
93 #define DSPARK_WEBVIEW_BACKEND 1
94 #ifndef NOMINMAX
95 #define NOMINMAX // webview.h pulls <windows.h>; keep min/max sane
96 #endif
97 #if defined(_MSC_VER)
98 #pragma warning(push, 1)
99 #endif
100 #include "webview/webview.h"
101 #if defined(_MSC_VER)
102 #pragma warning(pop)
103 #endif
104 #include <commctrl.h> // SetWindowSubclass: follow host resizes reliably
105 #if defined(_MSC_VER)
106 #pragma comment(lib, "comctl32.lib")
107 #endif
108 #include <memory>
109#elif defined(__APPLE__)
110 #define DSPARK_WEBVIEW_BACKEND 2
111 #include <objc/message.h>
112 #include <objc/runtime.h>
113 #include <dlfcn.h>
114#elif defined(__linux__)
115 #define DSPARK_WEBVIEW_BACKEND 3
116 #include <dlfcn.h>
117 #include <cstdint>
118 #include <type_traits>
119#else
120 #define DSPARK_WEBVIEW_BACKEND 0
121#endif
122
124
125// -- diagnostics -------------------------------------------------------------------
126//
127// Set the environment variable DSPARK_WEBVIEW_LOG to any non-empty value and
128// every editor in the process appends its host-interaction trace (attach,
129// size negotiation, parent resizes) to %TEMP%/DSParkWebView.log (or
130// $TMPDIR on POSIX). Costs nothing when the variable is absent.
131
132inline void debugLog(const char* format, ...) noexcept
133{
134 static std::FILE* file = []() -> std::FILE* {
135 char enabled[8] {};
136#if DSPARK_WEBVIEW_BACKEND == 1
137 if (GetEnvironmentVariableA("DSPARK_WEBVIEW_LOG", enabled,
138 sizeof(enabled)) == 0)
139 return nullptr;
140 char dir[MAX_PATH] {};
141 if (GetEnvironmentVariableA("TEMP", dir, sizeof(dir)) == 0)
142 dir[0] = '\0';
143 char path[MAX_PATH + 32];
144 std::snprintf(path, sizeof(path), "%s%sDSParkWebView.log",
145 dir, dir[0] != '\0' ? "\\" : "");
146 std::FILE* f = nullptr;
147 fopen_s(&f, path, "a");
148#elif defined(_WIN32)
149 // Stub backend on Windows (exceptions off): no <windows.h> here and
150 // no editor to trace, so the log stays off.
151 (void) enabled;
152 std::FILE* f = nullptr;
153#else
154 const char* env = std::getenv("DSPARK_WEBVIEW_LOG");
155 if (env == nullptr || env[0] == '\0') return nullptr;
156 (void) enabled;
157 const char* dir = std::getenv("TMPDIR");
158 char path[512];
159 std::snprintf(path, sizeof(path), "%s/DSParkWebView.log",
160 dir != nullptr ? dir : "/tmp");
161 std::FILE* f = std::fopen(path, "a");
162#endif
163 if (f != nullptr)
164 std::fprintf(f, "\n== editor session (layer built " __DATE__ " " __TIME__ ") ==\n");
165 return f;
166 }();
167 if (file == nullptr) return;
168 std::va_list args;
169 va_start(args, format);
170 std::vfprintf(file, format, args);
171 va_end(args);
172 std::fputc('\n', file);
173 std::fflush(file);
174}
175
176// -- JSON utilities ---------------------------------------------------------------
177//
178// The bridge protocol is small enough that a focused, locale-independent
179// formatter/parser beats dragging in a JSON library: host processes may run
180// with any LC_NUMERIC, where printf writes "6,5" (invalid JSON) and strtod
181// stops at the '.' - both classic plugin bugs.
182
184inline void appendJsonString(std::string& out, const char* s)
185{
186 out += '"';
187 for (const char* c = s; c != nullptr && *c != '\0'; ++c)
188 {
189 const unsigned char u = static_cast<unsigned char>(*c);
190 switch (u)
191 {
192 case '"': out += "\\\""; break;
193 case '\\': out += "\\\\"; break;
194 case '\n': out += "\\n"; break;
195 case '\r': out += "\\r"; break;
196 case '\t': out += "\\t"; break;
197 default:
198 if (u < 0x20)
199 {
200 char buf[8];
201 std::snprintf(buf, sizeof(buf), "\\u%04x", u);
202 out += buf;
203 }
204 else
205 out += static_cast<char>(u);
206 break;
207 }
208 }
209 out += '"';
210}
211
213inline void appendJsonNumber(std::string& out, double v)
214{
215 if (!std::isfinite(v))
216 {
217 out += '0';
218 return;
219 }
220 char buf[40];
221 std::snprintf(buf, sizeof(buf), "%.17g", v);
222 for (char* c = buf; *c != '\0'; ++c)
223 if (*c == ',') *c = '.'; // locale decimal comma -> JSON dot
224 out += buf;
225}
226
228inline const char* parseJsonNumber(const char* s, double& out) noexcept
229{
230 bool negative = false;
231 if (*s == '-' || *s == '+')
232 {
233 negative = (*s == '-');
234 ++s;
235 }
236 double v = 0.0;
237 bool any = false;
238 while (*s >= '0' && *s <= '9')
239 {
240 v = v * 10.0 + (*s - '0');
241 ++s;
242 any = true;
243 }
244 if (*s == '.')
245 {
246 ++s;
247 double f = 0.1;
248 while (*s >= '0' && *s <= '9')
249 {
250 v += (*s - '0') * f;
251 f *= 0.1;
252 ++s;
253 any = true;
254 }
255 }
256 if (!any) return nullptr;
257 if (*s == 'e' || *s == 'E')
258 {
259 ++s;
260 bool expNegative = false;
261 if (*s == '-' || *s == '+')
262 {
263 expNegative = (*s == '-');
264 ++s;
265 }
266 int e = 0;
267 bool eAny = false;
268 while (*s >= '0' && *s <= '9')
269 {
270 e = e * 10 + (*s - '0');
271 ++s;
272 eAny = true;
273 if (e > 308) { e = 309; } // saturate; isfinite check downstream
274 }
275 if (!eAny) return nullptr;
276 v *= std::pow(10.0, expNegative ? -e : e);
277 }
278 out = negative ? -v : v;
279 return s;
280}
281
284{
285 char op[12] {};
286 char id[64] {};
287 double value = 0.0;
288 int tokens = 0;
289};
290
292inline bool parsePostArgs(const char* s, PostArgs& out) noexcept
293{
294 auto skipWs = [&s] { while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') ++s; };
295 auto readString = [&s](char* dst, size_t cap) -> bool
296 {
297 ++s; // opening quote
298 size_t n = 0;
299 while (*s != '\0' && *s != '"')
300 {
301 char c = *s++;
302 if (c == '\\')
303 {
304 const char esc = *s++;
305 switch (esc)
306 {
307 case '"': c = '"'; break;
308 case '\\': c = '\\'; break;
309 case '/': c = '/'; break;
310 case 'n': c = '\n'; break;
311 case 'r': c = '\r'; break;
312 case 't': c = '\t'; break;
313 case 'b': c = '\b'; break;
314 case 'f': c = '\f'; break;
315 case 'u':
316 {
317 unsigned code = 0;
318 for (int k = 0; k < 4; ++k)
319 {
320 const char h = *s;
321 if (h >= '0' && h <= '9') code = code * 16 + unsigned(h - '0');
322 else if (h >= 'a' && h <= 'f') code = code * 16 + unsigned(h - 'a' + 10);
323 else if (h >= 'A' && h <= 'F') code = code * 16 + unsigned(h - 'A' + 10);
324 else return false;
325 ++s;
326 }
327 c = code < 128 ? static_cast<char>(code) : '?';
328 break;
329 }
330 case '\0': return false;
331 default: return false;
332 }
333 }
334 if (n + 1 < cap) dst[n++] = c;
335 }
336 if (*s != '"') return false;
337 ++s;
338 dst[n] = '\0';
339 return true;
340 };
341
342 out = PostArgs {};
343 skipWs();
344 if (*s != '[') return false;
345 ++s;
346 for (;;)
347 {
348 skipWs();
349 if (*s == ']') { ++s; return true; }
350 if (out.tokens > 0)
351 {
352 if (*s != ',') return false;
353 ++s;
354 skipWs();
355 }
356 if (*s == '"')
357 {
358 char* dst = out.tokens == 0 ? out.op : out.id;
359 const size_t cap = out.tokens == 0 ? sizeof(out.op) : sizeof(out.id);
360 if (out.tokens >= 2 || !readString(dst, cap)) return false;
361 }
362 else if (*s == 't' || *s == 'f' || *s == 'n')
363 {
364 // true/false/null can only appear as the value slot.
365 out.value = (*s == 't') ? 1.0 : 0.0;
366 while ((*s >= 'a' && *s <= 'z')) ++s;
367 }
368 else
369 {
370 const char* end = parseJsonNumber(s, out.value);
371 if (end == nullptr) return false;
372 s = end;
373 }
374 ++out.tokens;
375 if (out.tokens > 3) return false;
376 }
377}
378
379// -- the injected JS bridge ---------------------------------------------------------
380//
381// Runs at document start, before the page's own scripts. `__dsparkPost` is
382// the platform-provided uplink (a bound RPC on Windows, a WebKit message
383// handler wrapper on macOS); `__dsparkRecv` is the downlink the native side
384// drives through script evaluation. Everything user-facing lives in
385// `window.dspark`. The ~30 Hz poll starts after the native side answers the
386// "ready" handshake, and listeners replay the cached value on registration,
387// so page and native initialisation order can never race.
388
389inline constexpr const char* kBridgeJs =
390 "(function(){'use strict';"
391 "if(window.dspark){return;}"
392 "var ls={},vals={},meta=null,readyCbs=[];"
393 "function fire(id,v){var c=ls[id];if(c){for(var i=0;i<c.length;i++){c[i](v);}}}"
394 "window.__dsparkRecv=function(m){"
395 "if(m.type==='params'){"
396 "meta=m.params;"
397 "for(var i=0;i<meta.length;i++){vals[meta[i].id]=meta[i].value;}"
398 // KeepAspect editors scale their whole content with the window:
399 // the page keeps its DESIGN layout and is fitted into the window
400 // by the limiting axis, centered (letterboxed) when the host lets
401 // the user distort the ratio. Nothing can ever clip.
402 "if(m.design&&m.design.keepAspect&&!window.__dsparkFit){"
403 "var dw=m.design.width,dh=m.design.height;"
404 "window.__dsparkFit=function(){"
405 "var z=Math.min(window.innerWidth/dw,window.innerHeight/dh);"
406 "if(!(z>0&&isFinite(z))){z=1;}"
407 "var s=document.body.style;"
408 "s.width=dw+'px';s.height=dh+'px';"
409 "s.position='absolute';s.margin='0';"
410 "s.transformOrigin='0 0';s.transform='scale('+z+')';"
411 "s.left=((window.innerWidth-dw*z)/2)+'px';"
412 "s.top=((window.innerHeight-dh*z)/2)+'px';};"
413 "window.addEventListener('resize',window.__dsparkFit);"
414 "window.__dsparkFit();}"
415 "var rc=readyCbs;readyCbs=[];"
416 "for(var j=0;j<rc.length;j++){rc[j](meta);}"
417 "for(var k=0;k<meta.length;k++){fire(meta[k].id,meta[k].value);}"
418 "if(!window.__dsparkTimer){"
419 "window.__dsparkTimer=setInterval(function(){window.__dsparkPost('poll');},33);}"
420 "}else if(m.type==='values'){"
421 "for(var id in m.values){"
422 "if(vals[id]!==m.values[id]){vals[id]=m.values[id];fire(id,m.values[id]);}}"
423 "}};"
424 "window.dspark={"
425 "setParam:function(id,v){v=+v;vals[id]=v;window.__dsparkPost('set',id,v);fire(id,v);},"
426 "getParam:function(id){return vals[id];},"
427 "beginEdit:function(id){window.__dsparkPost('begin',id);},"
428 "endEdit:function(id){window.__dsparkPost('end',id);},"
429 "onParam:function(id,cb){(ls[id]=ls[id]||[]).push(cb);"
430 "if(meta!==null&&(id in vals)){cb(vals[id]);}},"
431 "onReady:function(cb){if(meta!==null){cb(meta);}else{readyCbs.push(cb);}}"
432 "};"
433 "Object.defineProperty(window.dspark,'params',{get:function(){return meta;}});"
434 "function hello(){window.__dsparkPost('ready');}"
435 "if(document.readyState==='complete'||document.readyState==='interactive'){hello();}"
436 "else{document.addEventListener('DOMContentLoaded',hello);}"
437 "})();";
438
439// -- host callbacks ----------------------------------------------------------------
440
448{
449 void* context = nullptr;
450 void (*setParam)(void* context, int index, double plainValue) = nullptr;
451 void (*beginEdit)(void* context, int index) = nullptr;
452 void (*endEdit)(void* context, int index) = nullptr;
453};
454
455#if DSPARK_WEBVIEW_BACKEND == 2
456
457// -- macOS: minimal Objective-C runtime glue ---------------------------------------
458//
459// WKWebView is driven entirely through objc_msgSend, so no Apple GUI headers
460// or link flags are needed; WebKit.framework is loaded on first use. The
461// script-message relay is ONE runtime-registered class shared by every
462// editor in the process: its ivar holds a {function, context} sink, keeping
463// the IMP free of template types (two different plugins in one host would
464// otherwise race to register the same class name with different code).
465
466namespace objc_glue {
467
468using ObjId = void*;
469
470template <typename Ret = ObjId, typename... Args>
471inline Ret call(ObjId target, const char* selector, Args... args) noexcept
472{
473 using Fn = Ret (*)(ObjId, SEL, Args...);
474 return reinterpret_cast<Fn>(&objc_msgSend)(target, sel_registerName(selector), args...);
475}
476
477inline ObjId cls(const char* name) noexcept
478{
479 return reinterpret_cast<ObjId>(objc_getClass(name));
480}
481
482inline ObjId nsString(const char* utf8) noexcept
483{
484 return call(cls("NSString"), "stringWithUTF8String:", utf8 ? utf8 : "");
485}
486
487struct Rect { double x = 0, y = 0, w = 0, h = 0; }; // CGRect-compatible
488
489struct MessageSink
490{
491 void (*fn)(void* context, const char* json) = nullptr;
492 void* context = nullptr;
493};
494
495inline Class relayClass() noexcept;
496
497inline void onScriptMessage(ObjId self, SEL, ObjId, ObjId message) noexcept
498{
499 Ivar ivar = class_getInstanceVariable(relayClass(), "dsparkSink");
500 if (ivar == nullptr) return;
501 auto* sink = reinterpret_cast<MessageSink*>(
502 object_getIvar(static_cast<id>(self), ivar));
503 if (sink == nullptr || sink->fn == nullptr) return;
504 ObjId body = call(message, "body");
505 if (body == nullptr) return;
506 const char* utf8 = call<const char*>(body, "UTF8String");
507 if (utf8 != nullptr)
508 sink->fn(sink->context, utf8);
509}
510
511inline Class relayClass() noexcept
512{
513 static Class registered = [] {
514 Class c = objc_allocateClassPair(objc_getClass("NSObject"),
515 "DSParkWebViewMessageRelay", 0);
516 if (c == nullptr) // another DSPark plugin in this process won the race
517 return objc_getClass("DSParkWebViewMessageRelay");
518 class_addIvar(c, "dsparkSink", sizeof(void*), alignof(void*) == 8 ? 3 : 2, "^v");
519 class_addMethod(c, sel_registerName("userContentController:didReceiveScriptMessage:"),
520 reinterpret_cast<IMP>(&onScriptMessage), "v@:@@");
521 objc_registerClassPair(c);
522 return c;
523 }();
524 return registered;
525}
526
527inline void setRelaySink(ObjId relay, MessageSink* sink) noexcept
528{
529 Ivar ivar = class_getInstanceVariable(relayClass(), "dsparkSink");
530 if (ivar != nullptr)
531 object_setIvar(static_cast<id>(relay), ivar, reinterpret_cast<id>(sink));
532}
533
534inline bool loadWebKit() noexcept
535{
536 static void* handle =
537 dlopen("/System/Library/Frameworks/WebKit.framework/WebKit", RTLD_LAZY | RTLD_GLOBAL);
538 return handle != nullptr;
539}
540
541} // namespace objc_glue
542
543#endif // DSPARK_WEBVIEW_BACKEND == 2
544
545#if DSPARK_WEBVIEW_BACKEND == 3
546
547// -- Linux: WebKitGTK resolved at runtime -------------------------------------------
548//
549// Every entry point comes from ONE dlopen of libwebkit2gtk (GTK3, GLib and
550// JavaScriptCore are its dependencies, so dlsym on that handle resolves them
551// all): no GTK headers, no pkg-config, no link-time dependency - plugins
552// keep building with the plain C++ toolchain and simply report the editor
553// unavailable where WebKitGTK is missing. GTK3 is required for GtkPlug
554// (XEmbed into the host's X11 window); GTK4 removed it, which is why the
555// 4.x webkit2gtk line is used and not webkitgtk-6.0.
556
557namespace gtk_glue {
558
561struct Api
562{
563 bool ok = false;
564 // GTK3 / GLib / GObject
565 int (*gtkInitCheck)(int*, char***) = nullptr;
566 void* (*plugNew)(unsigned long) = nullptr;
567 void (*containerAdd)(void*, void*) = nullptr;
568 void (*widgetShowAll)(void*) = nullptr;
569 void (*widgetSetVisible)(void*, int) = nullptr;
570 void (*widgetDestroy)(void*) = nullptr;
571 void (*windowSetDefaultSize)(void*, int, int) = nullptr;
572 void (*windowResize)(void*, int, int) = nullptr;
573 unsigned long (*signalConnectData)(void*, const char*, void (*)(), void*,
574 void*, int) = nullptr;
575 void (*signalHandlerDisconnect)(void*, unsigned long) = nullptr;
576 int (*mainContextPending)(void*) = nullptr;
577 int (*mainContextIteration)(void*, int) = nullptr;
578 void (*gFree)(void*) = nullptr;
579 // WebKit
580 void* (*webViewNew)() = nullptr;
581 void* (*viewGetContentManager)(void*) = nullptr;
582 int (*contentManagerRegisterHandler)(void*, const char*) = nullptr;
583 void (*contentManagerUnregisterHandler)(void*, const char*) = nullptr;
584 void* (*userScriptNew)(const char*, int, int, const char* const*,
585 const char* const*) = nullptr;
586 void (*contentManagerAddScript)(void*, void*) = nullptr;
587 void (*userScriptUnref)(void*) = nullptr;
588 void (*loadHtml)(void*, const char*, const char*) = nullptr;
589 // JS eval: run_javascript exists on every 4.0/4.1 (deprecated since
590 // 2.40); evaluate_javascript replaces it from 2.40 on. Either works.
591 void (*runJs)(void*, const char*, void*, void*, void*) = nullptr;
592 void (*evalJs)(void*, const char*, long, const char*, const char*,
593 void*, void*, void*) = nullptr;
594 void* (*jsResultGetValue)(void*) = nullptr;
595 char* (*jscValueToString)(void*) = nullptr;
596};
597
598inline Api loadApi() noexcept
599{
600 Api api {};
601 void* lib = dlopen("libwebkit2gtk-4.1.so.0", RTLD_LAZY | RTLD_LOCAL);
602 if (lib == nullptr)
603 lib = dlopen("libwebkit2gtk-4.0.so.37", RTLD_LAZY | RTLD_LOCAL);
604 if (lib == nullptr)
605 {
606 debugLog("linux engine: libwebkit2gtk not found (generic UI)");
607 return api;
608 }
609 auto load = [lib](auto& fn, const char* name) {
610 fn = reinterpret_cast<std::remove_reference_t<decltype(fn)>>(dlsym(lib, name));
611 };
612 load(api.gtkInitCheck, "gtk_init_check");
613 load(api.plugNew, "gtk_plug_new");
614 load(api.containerAdd, "gtk_container_add");
615 load(api.widgetShowAll, "gtk_widget_show_all");
616 load(api.widgetSetVisible, "gtk_widget_set_visible");
617 load(api.widgetDestroy, "gtk_widget_destroy");
618 load(api.windowSetDefaultSize, "gtk_window_set_default_size");
619 load(api.windowResize, "gtk_window_resize");
620 load(api.signalConnectData, "g_signal_connect_data");
621 load(api.signalHandlerDisconnect, "g_signal_handler_disconnect");
622 load(api.mainContextPending, "g_main_context_pending");
623 load(api.mainContextIteration, "g_main_context_iteration");
624 load(api.gFree, "g_free");
625 load(api.webViewNew, "webkit_web_view_new");
626 load(api.viewGetContentManager, "webkit_web_view_get_user_content_manager");
627 load(api.contentManagerRegisterHandler,
628 "webkit_user_content_manager_register_script_message_handler");
629 load(api.contentManagerUnregisterHandler,
630 "webkit_user_content_manager_unregister_script_message_handler");
631 load(api.userScriptNew, "webkit_user_script_new");
632 load(api.contentManagerAddScript, "webkit_user_content_manager_add_script");
633 load(api.userScriptUnref, "webkit_user_script_unref");
634 load(api.loadHtml, "webkit_web_view_load_html");
635 load(api.runJs, "webkit_web_view_run_javascript");
636 load(api.evalJs, "webkit_web_view_evaluate_javascript");
637 load(api.jsResultGetValue, "webkit_javascript_result_get_js_value");
638 load(api.jscValueToString, "jsc_value_to_string");
639 api.ok = api.gtkInitCheck != nullptr && api.plugNew != nullptr
640 && api.containerAdd != nullptr && api.widgetShowAll != nullptr
641 && api.widgetSetVisible != nullptr && api.widgetDestroy != nullptr
642 && api.windowSetDefaultSize != nullptr && api.windowResize != nullptr
643 && api.signalConnectData != nullptr
644 && api.signalHandlerDisconnect != nullptr
645 && api.mainContextPending != nullptr
646 && api.mainContextIteration != nullptr && api.gFree != nullptr
647 && api.webViewNew != nullptr && api.viewGetContentManager != nullptr
648 && api.contentManagerRegisterHandler != nullptr
649 && api.contentManagerUnregisterHandler != nullptr
650 && api.userScriptNew != nullptr
651 && api.contentManagerAddScript != nullptr
652 && api.userScriptUnref != nullptr && api.loadHtml != nullptr
653 && (api.runJs != nullptr || api.evalJs != nullptr)
654 && api.jsResultGetValue != nullptr && api.jscValueToString != nullptr;
655 if (!api.ok)
656 debugLog("linux engine: libwebkit2gtk found but symbols missing");
657 return api;
658}
659
661inline const Api& api() noexcept
662{
663 static const Api loaded = loadApi();
664 return loaded;
665}
666
667} // namespace gtk_glue
668
669#endif // DSPARK_WEBVIEW_BACKEND == 3
670
671// -- the editor --------------------------------------------------------------------
672
685template <typename P>
687{
688public:
689 static_assert(!HasEditor<P> || HasEditorHtml<P>,
690 "hasEditor is true but `static const char* editorHtml()` is missing");
691
693 static constexpr bool kAvailable = (DSPARK_WEBVIEW_BACKEND != 0);
694
702 static bool available() noexcept
703 {
704#if DSPARK_WEBVIEW_BACKEND == 3
705 return gtk_glue::api().ok;
706#else
707 return kAvailable;
708#endif
709 }
710
718 void pump() noexcept
719 {
720#if DSPARK_WEBVIEW_BACKEND == 3
721 if (created_)
722 pumpPlatform();
723#endif
724 }
725
726 Editor() = default;
728
729 Editor(const Editor&) = delete;
730 Editor& operator=(const Editor&) = delete;
731
739 bool create(void* parentWindow, const std::atomic<double>* shadows,
740 const HostCallbacks& host) noexcept
741 {
742 if (created_ || parentWindow == nullptr) return false;
743 shadows_ = shadows;
744 host_ = host;
745 parent_ = parentWindow;
746 created_ = createPlatform(parentWindow);
747 return created_;
748 }
749
751 void destroy() noexcept
752 {
753 if (!created_) return;
754 created_ = false;
755 destroyPlatform();
756 }
757
759 void setBounds(int width, int height) noexcept
760 {
761 if (created_ && width > 0 && height > 0)
762 setBoundsPlatform(width, height);
763 }
764
766 void setVisible(bool visible) noexcept
767 {
768 if (created_)
769 setVisiblePlatform(visible);
770 }
771
783 bool queryParentSize(int& width, int& height) const noexcept
784 {
785 return queryParentSizePlatform(width, height);
786 }
787
788 [[nodiscard]] bool created() const noexcept { return created_; }
789
790private:
791 static constexpr size_t kNumParams = P::parameters.size();
792
793 std::atomic<double> const* shadows_ = nullptr;
794 HostCallbacks host_ {};
795 void* parent_ = nullptr;
796 bool created_ = false;
797
798 static int indexOfId(const char* id) noexcept
799 {
800 for (size_t i = 0; i < kNumParams; ++i)
801 if (std::strcmp(P::parameters[i].id, id) == 0) return static_cast<int>(i);
802 return -1;
803 }
804
805 [[nodiscard]] double plainOf(size_t index) const noexcept
806 {
807 return toPlain(P::parameters[index],
808 shadows_[index].load(std::memory_order_relaxed));
809 }
810
811 // --- bridge protocol ---------------------------------------------------------
812
813 void handlePost(const char* json) noexcept
814 {
815 PostArgs msg;
816 if (!parsePostArgs(json, msg)) return;
817 if (std::strcmp(msg.op, "poll") == 0)
818 {
819 sendValues();
820 }
821 else if (std::strcmp(msg.op, "set") == 0)
822 {
823 const int idx = indexOfId(msg.id);
824 if (idx >= 0 && msg.tokens >= 3 && host_.setParam != nullptr)
825 host_.setParam(host_.context, idx, msg.value);
826 }
827 else if (std::strcmp(msg.op, "begin") == 0)
828 {
829 const int idx = indexOfId(msg.id);
830 if (idx >= 0 && host_.beginEdit != nullptr)
831 host_.beginEdit(host_.context, idx);
832 }
833 else if (std::strcmp(msg.op, "end") == 0)
834 {
835 const int idx = indexOfId(msg.id);
836 if (idx >= 0 && host_.endEdit != nullptr)
837 host_.endEdit(host_.context, idx);
838 }
839 else if (std::strcmp(msg.op, "ready") == 0)
840 {
841 sendMeta();
842 // Diagnostic: how the page actually sees the world (DPI bugs
843 // live exactly here). Logged only with DSPARK_WEBVIEW_LOG set.
844 evalPlatform("window.__dsparkPost('metric','innerWidth',window.innerWidth);"
845 "window.__dsparkPost('metric','innerHeight',window.innerHeight);"
846 "window.__dsparkPost('metric','dpr',window.devicePixelRatio||1);");
847 }
848 else if (std::strcmp(msg.op, "metric") == 0)
849 {
850 debugLog("page metric %s = %.2f", msg.id, msg.value);
851 }
852 }
853
854 void sendMeta()
855 {
856 std::string json = "{\"type\":\"params\",\"params\":[";
857 for (size_t i = 0; i < kNumParams; ++i)
858 {
859 const Param& p = P::parameters[i];
860 if (i > 0) json += ',';
861 json += "{\"id\":";
862 appendJsonString(json, p.id);
863 json += ",\"name\":";
864 appendJsonString(json, p.name);
865 json += ",\"min\":";
866 appendJsonNumber(json, p.minValue);
867 json += ",\"max\":";
868 appendJsonNumber(json, p.maxValue);
869 json += ",\"def\":";
870 appendJsonNumber(json, p.defValue);
871 json += ",\"unit\":";
872 appendJsonString(json, p.unit);
873 json += ",\"steps\":";
874 appendJsonNumber(json, p.steps);
875 if (p.labels != nullptr)
876 {
877 json += ",\"labels\":[";
878 for (int k = 0; k <= p.steps; ++k)
879 {
880 if (k > 0) json += ',';
881 appendJsonString(json, p.labels[k]);
882 }
883 json += ']';
884 }
885 json += ",\"value\":";
886 appendJsonNumber(json, plainOf(i));
887 json += '}';
888 }
889 json += "],\"design\":{\"width\":";
890 appendJsonNumber(json, editorSizeOf<P>().width);
891 json += ",\"height\":";
892 appendJsonNumber(json, editorSizeOf<P>().height);
893 json += ",\"keepAspect\":";
894 json += editorResizeOf<P>() == EditorResize::KeepAspect ? "true" : "false";
895 json += "}}";
896 sendRecv(json);
897 }
898
899 void sendValues()
900 {
901 std::string json = "{\"type\":\"values\",\"values\":{";
902 for (size_t i = 0; i < kNumParams; ++i)
903 {
904 if (i > 0) json += ',';
905 appendJsonString(json, P::parameters[i].id);
906 json += ':';
907 appendJsonNumber(json, plainOf(i));
908 }
909 json += "}}";
910 sendRecv(json);
911 }
912
913 void sendRecv(const std::string& payloadJson)
914 {
915 std::string js = "window.__dsparkRecv(";
916 js += payloadJson;
917 js += ");";
918 evalPlatform(js);
919 }
920
921 static constexpr bool debugFlag() noexcept
922 {
923 if constexpr (HasEditorDebug<P>) return true;
924 else return false;
925 }
926
929 static std::string pageHtml()
930 {
931#if !defined(DSPARK_NO_FILE_IO)
932 if constexpr (HasEditorDevFile<P>)
933 {
934 std::FILE* f = nullptr;
935#if defined(_WIN32)
936 fopen_s(&f, P::editorDevFile(), "rb");
937#else
938 f = std::fopen(P::editorDevFile(), "rb");
939#endif
940 if (f != nullptr)
941 {
942 std::string text;
943 char chunk[4096];
944 size_t got = 0;
945 while ((got = std::fread(chunk, 1, sizeof(chunk), f)) > 0)
946 text.append(chunk, got);
947 std::fclose(f);
948 if (!text.empty())
949 {
950 debugLog("page: dev file %s (%u bytes)", P::editorDevFile(),
951 static_cast<unsigned>(text.size()));
952 return text;
953 }
954 }
955 debugLog("page: dev file %s unreadable; embedded page used",
956 P::editorDevFile());
957 }
958#endif
959 return P::editorHtml();
960 }
961
962 // ==============================================================================
963 // Windows - WebView2 through the vendored webview library
964 // ==============================================================================
965#if DSPARK_WEBVIEW_BACKEND == 1
966
967 std::unique_ptr<webview::webview> wv_;
968 void* root_ = nullptr; // host top-level window (frame limits)
969 mutable SIZE frameChrome_ { -1, -1 }; // historical-minimum chrome; -1 = unmeasured
970 bool comInitialized_ = false;
971
972 bool createPlatform(void* parentWindow) noexcept
973 {
974 try
975 {
976 // WebView2 needs COM STA on this (UI) thread. DAWs have it there
977 // already (S_FALSE, harmless); bare hosts may not. Balanced in
978 // destroyPlatform. RPC_E_CHANGED_MODE (an MTA thread) fails the
979 // creation below and degrades to the host's generic UI.
980 comInitialized_ = SUCCEEDED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED));
981 wv_ = std::make_unique<webview::webview>(debugFlag(), parentWindow);
982 wv_->bind("__dsparkPost", [this](std::string request) -> std::string {
983 handlePost(request.c_str());
984 return std::string {};
985 });
986 wv_->init(kBridgeJs);
987 wv_->set_html(pageHtml());
988 // Track the host window directly: several hosts resize their
989 // plugin area without calling the format's size callbacks, so
990 // relying on those alone leaves the page stuck at the old size.
991 SetWindowSubclass(static_cast<HWND>(parentWindow), &onParentMessage,
992 reinterpret_cast<UINT_PTR>(this),
993 reinterpret_cast<DWORD_PTR>(this));
994 // And the host's TOP-LEVEL frame: WM_GETMINMAXINFO/WM_SIZING are
995 // the OS-level drag limits - the only ones no host can bypass.
996 // VST3 size negotiation only governs the inner plugin area; the
997 // outer frame belongs to the user, so min/max/aspect must be
998 // enforced where the WINDOW is dragged (see rootGuard for docks).
999 root_ = GetAncestor(static_cast<HWND>(parentWindow), GA_ROOT);
1000 if (root_ != nullptr)
1001 SetWindowSubclass(static_cast<HWND>(root_), &onRootMessage,
1002 reinterpret_cast<UINT_PTR>(this),
1003 reinterpret_cast<DWORD_PTR>(this));
1004 RECT parentBox {};
1005 GetClientRect(static_cast<HWND>(parentWindow), &parentBox);
1006 debugLog("create: parent=%p root=%p client=%ldx%ld", parentWindow,
1007 root_, parentBox.right, parentBox.bottom);
1008 return true;
1009 }
1010 catch (...) // e.g. WebView2 runtime missing -> host falls back to generic UI
1011 {
1012 wv_.reset();
1013 if (comInitialized_)
1014 {
1015 CoUninitialize();
1016 comInitialized_ = false;
1017 }
1018 return false;
1019 }
1020 }
1021
1022 static LRESULT CALLBACK onParentMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
1023 UINT_PTR, DWORD_PTR refData) noexcept
1024 {
1025 if (msg == WM_SIZE)
1026 {
1027 debugLog("parent WM_SIZE %dx%d", int(LOWORD(lp)), int(HIWORD(lp)));
1028 auto* editor = reinterpret_cast<Editor*>(refData);
1029 editor->setBounds(static_cast<int>(LOWORD(lp)), static_cast<int>(HIWORD(lp)));
1030 }
1031 return DefSubclassProc(hwnd, msg, wp, lp);
1032 }
1033
1034 // --- host frame limits (OS level) ----------------------------------------------
1035
1037 double frameDpiScale() const noexcept
1038 {
1039 const UINT dpi = parent_ != nullptr ? GetDpiForWindow(static_cast<HWND>(parent_))
1040 : 96u;
1041 return dpi > 0 ? dpi / 96.0 : 1.0;
1042 }
1043
1057 bool rootGuard(HWND root, SIZE& chrome) const noexcept
1058 {
1059 if (parent_ == nullptr || root == nullptr) return false;
1060 RECT rootBox {}, parentBox {};
1061 const bool measured = GetWindowRect(root, &rootBox)
1062 && GetClientRect(static_cast<HWND>(parent_), &parentBox);
1063 bool coherent = false;
1064 if (measured)
1065 {
1066 // Threshold separates plugin windows from docked editors: a
1067 // REAPER FX-chain window (plugin list beside the editor) sits
1068 // near 45-90% plugin area, an editor docked in a DAW main
1069 // window stays well under 25%.
1070 const double rootArea = double(rootBox.right - rootBox.left)
1071 * double(rootBox.bottom - rootBox.top);
1072 const double parentArea = double(parentBox.right) * double(parentBox.bottom);
1073 coherent = rootArea > 0.0 && parentArea > 0.0
1074 && parentArea / rootArea >= 1.0 / 3.0;
1075 }
1076 if (coherent)
1077 {
1078 // No tight cap here: legitimate host chrome can be large (the
1079 // REAPER FX-chain window keeps its plugin list beside the
1080 // editor, ~450px). Underestimating it lets the window shrink
1081 // past the point where the plugin minimum still fits - exactly
1082 // a clipped editor. The historical minimum below already stops
1083 // dead space from inflating these numbers over time.
1084 auto clampChrome = [](LONG v) -> LONG {
1085 return v < 0 ? 0 : (v > 1024 ? 1024 : v);
1086 };
1087 const LONG cx = clampChrome((rootBox.right - rootBox.left) - parentBox.right);
1088 const LONG cy = clampChrome((rootBox.bottom - rootBox.top) - parentBox.bottom);
1089 if (frameChrome_.cx < 0)
1090 {
1091 frameChrome_ = SIZE { cx, cy };
1092 debugLog("frame: steering host window, chrome=%ldx%ld", cx, cy);
1093 }
1094 else
1095 {
1096 if (cx < frameChrome_.cx) frameChrome_.cx = cx;
1097 if (cy < frameChrome_.cy) frameChrome_.cy = cy;
1098 }
1099 }
1100 // A truly docked editor never produces a coherent (dominant-area)
1101 // measurement, so the chrome stays unmeasured and we never steer.
1102 if (frameChrome_.cx < 0) return false;
1103 chrome = frameChrome_;
1104 return true;
1105 }
1106
1107 void applyMinMax(HWND root, MINMAXINFO* info) const noexcept
1108 {
1109 SIZE chrome {};
1110 if (info == nullptr || !rootGuard(root, chrome)) return;
1111 const EditorSize logical = editorSizeOf<P>();
1112 const double dpi = frameDpiScale();
1113 constexpr EditorResize mode = editorResizeOf<P>();
1114 const double lo = mode == EditorResize::Fixed ? 1.0 : kEditorMinSizeFactor;
1115 const double hi = mode == EditorResize::Fixed ? 1.0 : kEditorMaxSizeFactor;
1116 const LONG minW = LONG(logical.width * dpi * lo + 0.5) + chrome.cx;
1117 const LONG minH = LONG(logical.height * dpi * lo + 0.5) + chrome.cy;
1118 const LONG maxW = LONG(logical.width * dpi * hi + 0.5) + chrome.cx;
1119 const LONG maxH = LONG(logical.height * dpi * hi + 0.5) + chrome.cy;
1120 if (info->ptMinTrackSize.x < minW) info->ptMinTrackSize.x = minW;
1121 if (info->ptMinTrackSize.y < minH) info->ptMinTrackSize.y = minH;
1122 if (info->ptMaxTrackSize.x > maxW) info->ptMaxTrackSize.x = maxW;
1123 if (info->ptMaxTrackSize.y > maxH) info->ptMaxTrackSize.y = maxH;
1124 debugLog("frame minmax: track %ldx%ld .. %ldx%ld (chrome %ldx%ld)",
1125 info->ptMinTrackSize.x, info->ptMinTrackSize.y,
1126 info->ptMaxTrackSize.x, info->ptMaxTrackSize.y,
1127 chrome.cx, chrome.cy);
1128 }
1129
1130 void applySizing(HWND root, int edge, RECT* box) const noexcept
1131 {
1132 SIZE chrome {};
1133 if (box == nullptr || !rootGuard(root, chrome)) return;
1134 const EditorSize logical = editorSizeOf<P>();
1135 const double dpi = frameDpiScale();
1136 constexpr EditorResize mode = editorResizeOf<P>();
1137 // Unlike checkSizeConstraint (where the window is already fixed and
1138 // the plugin must fit INSIDE it), WM_SIZING reshapes the whole
1139 // window - so the dragged axis leads and the other may follow.
1140 const double lo = mode == EditorResize::Fixed ? 1.0 : kEditorMinSizeFactor;
1141 const double hi = mode == EditorResize::Fixed ? 1.0 : kEditorMaxSizeFactor;
1142 const double minW = logical.width * dpi * lo;
1143 const double maxW = logical.width * dpi * hi;
1144 const double minH = logical.height * dpi * lo;
1145 const double maxH = logical.height * dpi * hi;
1146 double w = double(box->right - box->left) - chrome.cx;
1147 double h = double(box->bottom - box->top) - chrome.cy;
1148 w = w < minW ? minW : (w > maxW ? maxW : w);
1149 h = h < minH ? minH : (h > maxH ? maxH : h);
1150 if constexpr (mode == EditorResize::KeepAspect)
1151 {
1152 // The dragged edge expresses the user's intent; the bounds above
1153 // are ratio-consistent, so the derived axis stays in range.
1154 const double ratio = double(logical.width) / logical.height;
1155 if (edge == WMSZ_TOP || edge == WMSZ_BOTTOM)
1156 w = h * ratio;
1157 else
1158 h = w / ratio;
1159 }
1160 const LONG newW = LONG(w + 0.5) + chrome.cx;
1161 const LONG newH = LONG(h + 0.5) + chrome.cy;
1162 const LONG oldW = box->right - box->left;
1163 const LONG oldH = box->bottom - box->top;
1164 // Anchor the side opposite to the dragged edge.
1165 if (edge == WMSZ_LEFT || edge == WMSZ_TOPLEFT || edge == WMSZ_BOTTOMLEFT)
1166 box->left = box->right - newW;
1167 else
1168 box->right = box->left + newW;
1169 if (edge == WMSZ_TOP || edge == WMSZ_TOPLEFT || edge == WMSZ_TOPRIGHT)
1170 box->top = box->bottom - newH;
1171 else
1172 box->bottom = box->top + newH;
1173 if (newW != oldW || newH != oldH)
1174 debugLog("frame sizing(edge %d): %ldx%ld -> %ldx%ld", edge,
1175 oldW, oldH, newW, newH);
1176 }
1177
1178 static LRESULT CALLBACK onRootMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
1179 UINT_PTR, DWORD_PTR refData) noexcept
1180 {
1181 const LRESULT result = DefSubclassProc(hwnd, msg, wp, lp);
1182 auto* editor = reinterpret_cast<Editor*>(refData);
1183 if (msg == WM_GETMINMAXINFO)
1184 editor->applyMinMax(hwnd, reinterpret_cast<MINMAXINFO*>(lp));
1185 else if (msg == WM_SIZING)
1186 {
1187 editor->applySizing(hwnd, static_cast<int>(wp),
1188 reinterpret_cast<RECT*>(lp));
1189 return TRUE;
1190 }
1191 return result;
1192 }
1193
1194 void destroyPlatform() noexcept
1195 {
1196 if (parent_ != nullptr)
1197 RemoveWindowSubclass(static_cast<HWND>(parent_), &onParentMessage,
1198 reinterpret_cast<UINT_PTR>(this));
1199 if (root_ != nullptr)
1200 {
1201 RemoveWindowSubclass(static_cast<HWND>(root_), &onRootMessage,
1202 reinterpret_cast<UINT_PTR>(this));
1203 root_ = nullptr;
1204 }
1205 try { wv_.reset(); }
1206 catch (...) {}
1207 if (comInitialized_)
1208 {
1209 CoUninitialize();
1210 comInitialized_ = false;
1211 }
1212 }
1213
1214 void setBoundsPlatform(int width, int height) noexcept
1215 {
1216 // The parent's real client box is the single source of truth: hosts
1217 // and formats disagree about who announces sizes when, but the web
1218 // widget must simply fill whatever the host window currently is.
1219 const int requestedW = width;
1220 const int requestedH = height;
1221 RECT rect {};
1222 if (parent_ != nullptr && GetClientRect(static_cast<HWND>(parent_), &rect)
1223 && rect.right > 0 && rect.bottom > 0)
1224 {
1225 width = static_cast<int>(rect.right);
1226 height = static_cast<int>(rect.bottom);
1227 }
1228 debugLog("setBounds(%d,%d) -> widget %dx%d", requestedW, requestedH,
1229 width, height);
1230 try
1231 {
1232 if (auto widget = wv_->widget(); widget.ok())
1233 MoveWindow(static_cast<HWND>(widget.value()), 0, 0, width, height, TRUE);
1234 }
1235 catch (...) {}
1236 }
1237
1238 void setVisiblePlatform(bool visible) noexcept
1239 {
1240 try
1241 {
1242 if (auto widget = wv_->widget(); widget.ok())
1243 ShowWindow(static_cast<HWND>(widget.value()), visible ? SW_SHOW : SW_HIDE);
1244 }
1245 catch (...) {}
1246 }
1247
1248 bool queryParentSizePlatform(int& width, int& height) const noexcept
1249 {
1250 RECT rect {};
1251 if (parent_ == nullptr || !GetClientRect(static_cast<HWND>(parent_), &rect))
1252 return false;
1253 if (rect.right <= 0 || rect.bottom <= 0) return false;
1254 width = static_cast<int>(rect.right);
1255 height = static_cast<int>(rect.bottom);
1256 return true;
1257 }
1258
1259 void evalPlatform(const std::string& js) noexcept
1260 {
1261 try
1262 {
1263 if (wv_) wv_->eval(js);
1264 }
1265 catch (...) {}
1266 }
1267
1268 // ==============================================================================
1269 // macOS - WKWebView through the Objective-C runtime
1270 // ==============================================================================
1271#elif DSPARK_WEBVIEW_BACKEND == 2
1272
1273 objc_glue::ObjId webView_ = nullptr; // WKWebView (retained)
1274 objc_glue::ObjId configuration_ = nullptr; // WKWebViewConfiguration (retained)
1275 objc_glue::ObjId relay_ = nullptr; // script-message relay (retained)
1276 objc_glue::MessageSink sink_ {};
1277
1278 static void sinkTrampoline(void* context, const char* json) noexcept
1279 {
1280 static_cast<Editor*>(context)->handlePost(json);
1281 }
1282
1283 bool createPlatform(void* parentWindow) noexcept
1284 {
1285 namespace og = objc_glue;
1286 if (!og::loadWebKit()) return false;
1287 Class relayCls = og::relayClass();
1288 if (relayCls == nullptr) return false;
1289
1290 sink_ = { &sinkTrampoline, this };
1291 relay_ = og::call(og::call(reinterpret_cast<og::ObjId>(relayCls), "alloc"), "init");
1292 if (relay_ == nullptr) return false;
1293 og::setRelaySink(relay_, &sink_);
1294
1295 configuration_ = og::call(og::cls("WKWebViewConfiguration"), "new");
1296 og::ObjId contentController = og::call(configuration_, "userContentController");
1297 og::call<void>(contentController, "addScriptMessageHandler:name:",
1298 relay_, og::nsString("dspark"));
1299
1300 // The uplink + bridge run at document start, before the page scripts.
1301 std::string bootstrap =
1302 "window.__dsparkPost=function(){"
1303 "window.webkit.messageHandlers.dspark.postMessage("
1304 "JSON.stringify(Array.prototype.slice.call(arguments)));};";
1305 bootstrap += kBridgeJs;
1306 og::ObjId script = og::call(og::call(og::cls("WKUserScript"), "alloc"),
1307 "initWithSource:injectionTime:forMainFrameOnly:",
1308 og::nsString(bootstrap.c_str()),
1309 static_cast<long>(0) /* AtDocumentStart */,
1310 static_cast<signed char>(1));
1311 og::call<void>(contentController, "addUserScript:", script);
1312 og::call<void>(script, "release");
1313
1314 const og::Rect frame {}; // resized right after by the backend
1315 webView_ = og::call(og::call(og::cls("WKWebView"), "alloc"),
1316 "initWithFrame:configuration:", frame, configuration_);
1317 if (webView_ == nullptr)
1318 {
1319 destroyPlatform();
1320 return false;
1321 }
1322 // Follow the host view on resize: width + height autoresize (2 | 16).
1323 og::call<void>(webView_, "setAutoresizingMask:", static_cast<unsigned long>(18));
1324 og::call<void>(static_cast<og::ObjId>(parentWindow), "addSubview:", webView_);
1325 og::call<void>(webView_, "loadHTMLString:baseURL:",
1326 og::nsString(pageHtml().c_str()), static_cast<og::ObjId>(nullptr));
1327 return true;
1328 }
1329
1330 void destroyPlatform() noexcept
1331 {
1332 namespace og = objc_glue;
1333 if (configuration_ != nullptr)
1334 {
1335 og::ObjId contentController = og::call(configuration_, "userContentController");
1336 og::call<void>(contentController, "removeScriptMessageHandlerForName:",
1337 og::nsString("dspark"));
1338 }
1339 if (webView_ != nullptr)
1340 {
1341 og::call<void>(webView_, "removeFromSuperview");
1342 og::call<void>(webView_, "release");
1343 webView_ = nullptr;
1344 }
1345 if (configuration_ != nullptr)
1346 {
1347 og::call<void>(configuration_, "release");
1348 configuration_ = nullptr;
1349 }
1350 if (relay_ != nullptr)
1351 {
1352 og::call<void>(relay_, "release");
1353 relay_ = nullptr;
1354 }
1355 }
1356
1357 void setBoundsPlatform(int width, int height) noexcept
1358 {
1359 const objc_glue::Rect frame { 0, 0, static_cast<double>(width),
1360 static_cast<double>(height) };
1361 objc_glue::call<void>(webView_, "setFrame:", frame);
1362 }
1363
1364 void setVisiblePlatform(bool visible) noexcept
1365 {
1366 objc_glue::call<void>(webView_, "setHidden:",
1367 static_cast<signed char>(visible ? 0 : 1));
1368 }
1369
1370 bool queryParentSizePlatform(int&, int&) const noexcept
1371 {
1372 // Cocoa views are logical-size and the autoresizing mask follows the
1373 // host view, so the negotiated size is already authoritative here.
1374 return false;
1375 }
1376
1377 void evalPlatform(const std::string& js) noexcept
1378 {
1379 if (webView_ != nullptr)
1380 objc_glue::call<void>(webView_, "evaluateJavaScript:completionHandler:",
1381 objc_glue::nsString(js.c_str()),
1382 static_cast<objc_glue::ObjId>(nullptr));
1383 }
1384
1385 // ==============================================================================
1386 // Linux - WebKitGTK in a GtkPlug, embedded in the host's X11 window
1387 // ==============================================================================
1388#elif DSPARK_WEBVIEW_BACKEND == 3
1389
1390 void* plug_ = nullptr; // GtkPlug (XEmbed top-level), owns the tree
1391 void* webView_ = nullptr; // WebKitWebView widget (child of the plug)
1392 void* contentManager_ = nullptr; // WebKitUserContentManager (view-owned)
1393 unsigned long messageSignal_ = 0;
1394
1395 static void onScriptMessage(void*, void* jsResult, void* userData) noexcept
1396 {
1397 auto* self = static_cast<Editor*>(userData);
1398 const auto& gtk = gtk_glue::api();
1399 void* value = gtk.jsResultGetValue(jsResult);
1400 if (value == nullptr) return;
1401 char* text = gtk.jscValueToString(value);
1402 if (text != nullptr)
1403 {
1404 self->handlePost(text);
1405 gtk.gFree(text);
1406 }
1407 }
1408
1409 bool createPlatform(void* parentWindow) noexcept
1410 {
1411 const auto& gtk = gtk_glue::api();
1412 if (!gtk.ok) return false;
1413 // Idempotent when the host (or another plugin) initialised GTK; fails
1414 // cleanly with no usable display (Wayland-only sessions, headless).
1415 if (!gtk.gtkInitCheck(nullptr, nullptr))
1416 {
1417 debugLog("linux create: gtk_init_check failed (no display)");
1418 return false;
1419 }
1420 plug_ = gtk.plugNew(static_cast<unsigned long>(
1421 reinterpret_cast<std::uintptr_t>(parentWindow)));
1422 if (plug_ == nullptr) return false;
1423 webView_ = gtk.webViewNew();
1424 if (webView_ == nullptr)
1425 {
1426 destroyPlatform();
1427 return false;
1428 }
1429 contentManager_ = gtk.viewGetContentManager(webView_);
1430 gtk.contentManagerRegisterHandler(contentManager_, "dspark");
1431 messageSignal_ = gtk.signalConnectData(
1432 contentManager_, "script-message-received::dspark",
1433 reinterpret_cast<void (*)()>(&onScriptMessage), this, nullptr, 0);
1434 // The uplink + bridge run at document start, before the page scripts
1435 // (WebKitGTK exposes the same window.webkit.messageHandlers namespace
1436 // as WKWebView).
1437 std::string bootstrap =
1438 "window.__dsparkPost=function(){"
1439 "window.webkit.messageHandlers.dspark.postMessage("
1440 "JSON.stringify(Array.prototype.slice.call(arguments)));};";
1441 bootstrap += kBridgeJs;
1442 void* script = gtk.userScriptNew(bootstrap.c_str(),
1443 1 /* INJECT_TOP_FRAME */,
1444 0 /* INJECT_AT_DOCUMENT_START */,
1445 nullptr, nullptr);
1446 gtk.contentManagerAddScript(contentManager_, script);
1447 gtk.userScriptUnref(script);
1448 gtk.containerAdd(plug_, webView_);
1449 gtk.loadHtml(webView_, pageHtml().c_str(), nullptr);
1450 gtk.widgetShowAll(plug_);
1451 debugLog("linux create: plug for X11 window %p", parentWindow);
1452 pumpPlatform(); // realize + map promptly, before the first host tick
1453 return true;
1454 }
1455
1456 void destroyPlatform() noexcept
1457 {
1458 const auto& gtk = gtk_glue::api();
1459 if (!gtk.ok) return;
1460 if (contentManager_ != nullptr)
1461 {
1462 if (messageSignal_ != 0)
1463 gtk.signalHandlerDisconnect(contentManager_, messageSignal_);
1464 gtk.contentManagerUnregisterHandler(contentManager_, "dspark");
1465 }
1466 if (plug_ != nullptr)
1467 gtk.widgetDestroy(plug_); // destroys the whole widget tree
1468 plug_ = nullptr;
1469 webView_ = nullptr;
1470 contentManager_ = nullptr;
1471 messageSignal_ = 0;
1472 pumpPlatform(); // flush the destroy events while we still can
1473 }
1474
1475 void setBoundsPlatform(int width, int height) noexcept
1476 {
1477 const auto& gtk = gtk_glue::api();
1478 if (plug_ == nullptr) return;
1479 gtk.windowSetDefaultSize(plug_, width, height); // pre-map size
1480 gtk.windowResize(plug_, width, height); // post-map size
1481 pumpPlatform();
1482 }
1483
1484 void setVisiblePlatform(bool visible) noexcept
1485 {
1486 if (plug_ != nullptr)
1487 gtk_glue::api().widgetSetVisible(plug_, visible ? 1 : 0);
1488 }
1489
1490 bool queryParentSizePlatform(int&, int&) const noexcept
1491 {
1492 // The plug follows the host window through XEmbed configure events,
1493 // so the negotiated size is authoritative here (as on macOS).
1494 return false;
1495 }
1496
1497 void evalPlatform(const std::string& js) noexcept
1498 {
1499 const auto& gtk = gtk_glue::api();
1500 if (webView_ == nullptr) return;
1501 if (gtk.runJs != nullptr)
1502 gtk.runJs(webView_, js.c_str(), nullptr, nullptr, nullptr);
1503 else
1504 gtk.evalJs(webView_, js.c_str(), -1, nullptr, nullptr,
1505 nullptr, nullptr, nullptr);
1506 }
1507
1508 void pumpPlatform() noexcept
1509 {
1510 const auto& gtk = gtk_glue::api();
1511 if (!gtk.ok) return;
1512 // Bounded: a source that is always ready (an idle handler re-arming
1513 // itself, an animation) must not trap the host's UI thread here -
1514 // whatever is left runs on the next host tick.
1515 for (int i = 0; i < kMaxPumpIterations && gtk.mainContextPending(nullptr) != 0; ++i)
1516 gtk.mainContextIteration(nullptr, 0);
1517 }
1518
1519 static constexpr int kMaxPumpIterations = 256;
1520
1521 // ==============================================================================
1522 // Other platforms - stub (hosts fall back to their generic editor)
1523 // ==============================================================================
1524#else
1525
1526 bool createPlatform(void*) noexcept { return false; }
1527 void destroyPlatform() noexcept {}
1528 void setBoundsPlatform(int, int) noexcept {}
1529 void setVisiblePlatform(bool) noexcept {}
1530 bool queryParentSizePlatform(int&, int&) const noexcept { return false; }
1531 void evalPlatform(const std::string&) noexcept {}
1532
1533#endif
1534};
1535
1536} // namespace dspark::plugin::webview_ui
One embedded WebView editor instance for plugin class P. Owned by the format backends (the VST3 IPlug...
Editor & operator=(const Editor &)=delete
bool queryParentSize(int &width, int &height) const noexcept
void setVisible(bool visible) noexcept
bool create(void *parentWindow, const std::atomic< double > *shadows, const HostCallbacks &host) noexcept
Editor(const Editor &)=delete
void setBounds(int width, int height) noexcept
static const char* editorHtml() - the editor page (HTML/CSS/JS), usually a raw string literal....
Custom-editor switch. With hasEditor = false (or absent) hosts show their generic parameter UI....
void appendJsonNumber(std::string &out, double v)
Appends a JSON number, immune to the process locale.
void appendJsonString(std::string &out, const char *s)
Appends a JSON string literal (quotes included, control chars escaped).
const char * parseJsonNumber(const char *s, double &out) noexcept
Locale-independent number parse; returns the end pointer or nullptr.
bool parsePostArgs(const char *s, PostArgs &out) noexcept
Parses the bridge's JSON array of scalars (op + optional id/number).
void debugLog(const char *format,...) noexcept
constexpr const char * kBridgeJs
constexpr double kEditorMinSizeFactor
Resize bounds: hosts may shrink to half and grow to 3x the declared size.
EditorResize
How the host may resize the editor window. Declare static constexpr EditorResize editorResize = ....
@ Fixed
The window is exactly editorSize; hosts cannot drag-resize it.
@ KeepAspect
Drag-resizable, locked to the declared width:height ratio.
constexpr double toPlain(const Param &p, double normalized) noexcept
Normalized [0, 1] -> plain [min, max], snapped for stepped params.
constexpr double kEditorMaxSizeFactor
How the editor reaches back into the format wrapper. Plain function pointers + context (no std::funct...
void(* beginEdit)(void *context, int index)
void(* endEdit)(void *context, int index)
void(* setParam)(void *context, int index, double plainValue)
One decoded bridge message: [op, id?, value?].