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