basalt-gtk 0.0.0-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runLinux.js +29 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +362 -0
  5. package/native/gtk/ColorSchemeGtk.cpp +58 -0
  6. package/native/gtk/ComponentRegistryGtk.cpp +101 -0
  7. package/native/gtk/FontRegistryFontconfig.cpp +127 -0
  8. package/native/gtk/GtkAnimationChoreographer.cpp +73 -0
  9. package/native/gtk/GtkAnimationChoreographer.h +49 -0
  10. package/native/gtk/GtkDropTarget.cpp +269 -0
  11. package/native/gtk/GtkDropTarget.h +40 -0
  12. package/native/gtk/GtkFocus.cpp +296 -0
  13. package/native/gtk/GtkFocus.h +113 -0
  14. package/native/gtk/GtkImageLoader.cpp +128 -0
  15. package/native/gtk/GtkImageLoader.h +87 -0
  16. package/native/gtk/GtkKeyEvents.cpp +91 -0
  17. package/native/gtk/GtkKeyEvents.h +21 -0
  18. package/native/gtk/GtkMenuBar.cpp +45 -0
  19. package/native/gtk/GtkMountingManager.cpp +1027 -0
  20. package/native/gtk/GtkMountingManager.h +146 -0
  21. package/native/gtk/GtkRunLoopObserver.cpp +77 -0
  22. package/native/gtk/GtkRunLoopObserver.h +41 -0
  23. package/native/gtk/GtkScrollView.cpp +626 -0
  24. package/native/gtk/GtkScrollView.h +209 -0
  25. package/native/gtk/GtkSkiaContext.cpp +248 -0
  26. package/native/gtk/GtkSkiaContext.h +62 -0
  27. package/native/gtk/GtkSkiaModule.cpp +80 -0
  28. package/native/gtk/GtkSkiaModule.h +51 -0
  29. package/native/gtk/GtkSkiaPeer.cpp +285 -0
  30. package/native/gtk/GtkSkiaPeer.h +63 -0
  31. package/native/gtk/GtkTextInput.cpp +428 -0
  32. package/native/gtk/GtkTextInput.h +123 -0
  33. package/native/gtk/GtkTextPeer.cpp +425 -0
  34. package/native/gtk/GtkTextPeer.h +87 -0
  35. package/native/gtk/GtkTitleBar.cpp +273 -0
  36. package/native/gtk/GtkTitleBar.h +96 -0
  37. package/native/gtk/GtkTitleBarLayout.cpp +45 -0
  38. package/native/gtk/GtkTitleBarLayout.h +36 -0
  39. package/native/gtk/GtkTouchDispatcher.cpp +671 -0
  40. package/native/gtk/GtkTouchDispatcher.h +139 -0
  41. package/native/gtk/GtkWindowControl.cpp +223 -0
  42. package/native/gtk/GtkWindowModule.cpp +194 -0
  43. package/native/gtk/GtkWindowModule.h +62 -0
  44. package/native/gtk/PangoTextLayout.cpp +295 -0
  45. package/native/gtk/PangoTextLayout.h +54 -0
  46. package/native/gtk/PangoTextLayoutManager.cpp +140 -0
  47. package/native/gtk/PlatformServicesGtk.cpp +583 -0
  48. package/native/gtk/RnView.cpp +1407 -0
  49. package/native/gtk/RnView.h +339 -0
  50. package/native/gtk/demo_layout_gtk.cpp +57 -0
  51. package/native/gtk/main_gtk.cpp +1981 -0
  52. package/native/gtk/mount_harness_gtk.cpp +143 -0
  53. package/native/tests/TestMainGtk.cpp +25 -0
  54. package/native/tests/notification_stub.cpp +103 -0
  55. package/native/tests/test_accessibility.cpp +251 -0
  56. package/native/tests/test_focus.cpp +194 -0
  57. package/native/tests/test_hittest.cpp +242 -0
  58. package/native/tests/test_image.cpp +135 -0
  59. package/native/tests/test_key_events_gtk.cpp +112 -0
  60. package/native/tests/test_mounting.cpp +415 -0
  61. package/native/tests/test_scrollview.cpp +258 -0
  62. package/native/tests/test_skia_texture.cpp +120 -0
  63. package/native/tests/test_text.cpp +147 -0
  64. package/native/tests/test_textinput.cpp +598 -0
  65. package/native/tests/test_titlebar.cpp +137 -0
  66. package/native/tests/test_titlebar_drag.cpp +138 -0
  67. package/native/tests/test_version.cpp +49 -0
  68. package/native/tests/test_view.cpp +162 -0
  69. package/native/tests/test_viewprops.cpp +254 -0
  70. package/package.json +26 -0
  71. package/react-native.config.js +21 -0
@@ -0,0 +1,1981 @@
1
+ // basalt-core — the host process.
2
+ //
3
+ // Constructs a ReactHost, loads a script into Hermes, and runs one surface in
4
+ // a GtkWindow. Everything on screen from here on is produced by Fabric: the
5
+ // mutation stream arrives through GtkMountingManager exactly as it does on
6
+ // iOS and Android, and nothing hand-builds a ShadowViewMutation.
7
+ //
8
+ // The pieces a host has to supply, and where each one comes from:
9
+ //
10
+ // IMountingManager GtkMountingManager (this repo)
11
+ // RunLoopObserverManager ReactCxxPlatform (event beat)
12
+ // AnimationChoreographer GtkAnimationChoreographer (this repo, frame clock)
13
+ // ContextContainer http + websocket client factories, below
14
+ // ComponentRegistryFactory ComponentRegistry (via the mounting manager)
15
+ //
16
+ // Threading: GTK owns this thread. ReactHost spins up its own JS thread and
17
+ // every mount is marshalled back here by GtkMountingManager. Nothing below
18
+ // touches a widget off the main thread.
19
+
20
+ #include "GtkAnimationChoreographer.h"
21
+ #include "AppIdentity.h"
22
+ #include "AppPaths.h"
23
+ #include "DevMenu.h"
24
+ #include "WindowControl.h"
25
+ #include "WindowHost.h"
26
+ #include "DialogModule.h"
27
+ #include "MenuModel.h"
28
+ #include "MenuModule.h"
29
+ #include "WindowsModule.h"
30
+ #include "TestKeys.h"
31
+ #include "TestQuitFile.h"
32
+ #include "TestSettle.h"
33
+ #include "GtkMountingManager.h"
34
+
35
+ #include <react/io/ImageLoaderModule.h>
36
+ #include "GtkRunLoopObserver.h"
37
+ #include "GtkFocus.h"
38
+ #include "LogBoxSurface.h"
39
+
40
+ #include <react/renderer/animated/NativeAnimatedNodesManagerProvider.h>
41
+ #include "GtkTouchDispatcher.h"
42
+ #include "RnView.h"
43
+
44
+ #include <glog/logging.h>
45
+ #include <jsi/jsi.h>
46
+ #include <logger/react_native_log.h>
47
+ #include <react/http/IHttpClient.h>
48
+ #include <react/http/IWebSocketClient.h>
49
+ #include <react/logging/DefaultOnJsErrorHandler.h>
50
+ #include <react/renderer/core/LayoutConstraints.h>
51
+ #include <react/renderer/core/LayoutContext.h>
52
+ #include <react/runtime/ReactHost.h>
53
+ #include <react/runtime/ReactInstanceConfig.h>
54
+ #include <react/utils/ContextContainer.h>
55
+ #include <react/utils/RunLoopObserverManager.h>
56
+
57
+ #include <gtk/gtk.h>
58
+
59
+ #include <algorithm>
60
+ #include <cstdlib>
61
+ #include <exception>
62
+ #include "AppearanceModule.h"
63
+ #include "BlobModule.h"
64
+ #include "CoreModules.h"
65
+ #include "ColorScheme.h"
66
+ #include "DevBundle.h"
67
+ #include "DragAndDrop.h"
68
+ #include "GtkDropTarget.h"
69
+ #include "GtkTitleBar.h"
70
+ // Which parts of an app-drawn header drag the window; see the gesture below.
71
+ #include "GtkTitleBarLayout.h"
72
+ #include "GtkWindowModule.h"
73
+
74
+ #ifdef BASALT_HAS_SKIA
75
+ #include "GtkSkiaModule.h"
76
+ #endif
77
+ #include "ExpoModules.h"
78
+ #include "GestureHandlerModule.h"
79
+ #include "ReanimatedModule.h"
80
+ #include "JsRuntimeAccess.h"
81
+ #include "PackageModules.h"
82
+ #include "UIManagerAccess.h"
83
+ #include "WorkletsModule.h"
84
+ #include "ExpoRuntime.h"
85
+ #include "TurboModuleProxy.h"
86
+ #include "PlatformConstantsModule.h"
87
+ #include "SourceCodeModule.h"
88
+ #include "StatusBarModule.h"
89
+
90
+ #include <react/featureflags/ReactNativeFeatureFlags.h>
91
+ #include <react/featureflags/ReactNativeFeatureFlagsDefaults.h>
92
+
93
+ #include <glib-unix.h>
94
+
95
+ #include <csignal>
96
+ #include <memory>
97
+ #include <string>
98
+
99
+ using facebook::react::ContextContainer;
100
+ using facebook::react::Float;
101
+ using facebook::react::LayoutConstraints;
102
+ using facebook::react::LayoutContext;
103
+ using facebook::react::LayoutDirection;
104
+ using facebook::react::ReactHost;
105
+ using facebook::react::ReactInstanceConfig;
106
+ using facebook::react::RunLoopObserverManager;
107
+ using facebook::react::Size;
108
+ using facebook::react::SurfaceId;
109
+
110
+ namespace {
111
+
112
+ // In Fabric a SurfaceId *is* the root shadow node's tag, which is what lets the
113
+ // surface root live in the mounting manager's registry like any other view.
114
+ constexpr SurfaceId kSurfaceId = 1;
115
+
116
+ // React Native's error inspector, which is a surface of its own -- registered
117
+ // by AppRegistry under the name "LogBox", exactly as an app registers its own
118
+ // component. Started over the app's when NativeLogBox.show() asks; see
119
+ // core/LogBoxSurface.h.
120
+ constexpr SurfaceId kLogBoxSurfaceId = 2;
121
+
122
+ constexpr int kInitialWidth = 900;
123
+ constexpr int kInitialHeight = 700;
124
+
125
+ // The entry point for the *scriptless* mode, where the bundle is a hand-written
126
+ // script talking to nativeFabricUIManager directly rather than a React app.
127
+ // See js/demo.js. Only used when no module name is given.
128
+ constexpr const char *kRenderFunctionName = "basaltRender";
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Host state
132
+ // ---------------------------------------------------------------------------
133
+
134
+ // One window, and the surface in it.
135
+ //
136
+ // A window is a surface: a surface is what has a size, a layout context and a
137
+ // root shadow node, and two windows sharing one would be two windows sharing a
138
+ // layout. So the id a window is known by *is* its surface id, and there is no
139
+ // second numbering to keep in step. See core/WindowHost.h.
140
+ //
141
+ // Everything here used to be a field on Host, because there was only ever one.
142
+ // What is still on Host is what is genuinely per-process -- the ReactHost, the
143
+ // mounting manager, the run loop -- and what this project has not yet made
144
+ // per-window: the title bar and the error inspector are the main window's.
145
+ struct HostWindow {
146
+ facebook::react::SurfaceId surfaceId{0};
147
+ GtkWindow *window{nullptr};
148
+ RnView *root{nullptr};
149
+ // The surface root sits in this, with room above it for the window controls
150
+ // the host draws when the title bar is hidden.
151
+ GtkWidget *overlay{nullptr};
152
+ std::unique_ptr<basalt::GtkTouchDispatcher> touchDispatcher;
153
+ std::unique_ptr<basalt::GtkFocusManager> focusManager;
154
+ int scaleFactor{1};
155
+ // Back to the host, for the GTK callbacks that take one pointer.
156
+ struct Host *host{nullptr};
157
+ };
158
+
159
+ struct Host {
160
+ // Every window, main one first. Never empty after onActivate.
161
+ std::vector<std::unique_ptr<HostWindow>> windows;
162
+
163
+ // The window the app was started in. Shorthand for windows.front(), which is
164
+ // what every part of this host that is not yet per-window uses.
165
+ // An empty window with null fields before there is a real one, rather than
166
+ // dereferencing an empty vector. Several things here run before the window
167
+ // exists -- a repaint request, the spinner timer -- and each one already
168
+ // guards on `window == nullptr`, which is exactly what this keeps true.
169
+ HostWindow &main() {
170
+ static HostWindow none;
171
+ return windows.empty() ? none : *windows.front();
172
+ }
173
+
174
+ HostWindow *windowFor(facebook::react::SurfaceId surfaceId) {
175
+ for (const auto &candidate : windows) {
176
+ if (candidate->surfaceId == surfaceId) {
177
+ return candidate.get();
178
+ }
179
+ }
180
+ return nullptr;
181
+ }
182
+
183
+ // The GtkApplication, so a window can be made after startup. A window must
184
+ // belong to one, or GTK will not manage it.
185
+ GtkApplication *application{nullptr};
186
+ // What `gtk_application_inhibit` handed back while an app was holding the
187
+ // session open, or 0. See the "query-end" handler.
188
+ guint quitInhibitCookie{0};
189
+
190
+ std::shared_ptr<basalt::GtkMountingManager> mountingManager;
191
+ std::shared_ptr<RunLoopObserverManager> runLoopObserverManager;
192
+ std::shared_ptr<basalt::GtkAnimationChoreographer> choreographer;
193
+ // The error inspector's own root when it is showing. It goes above the main
194
+ // window's controls, which is what a modal box should do -- and it is the
195
+ // main window's alone: an error is about the app rather than about a window.
196
+ RnView *logBoxRoot{nullptr};
197
+ std::unique_ptr<ReactHost> reactHost;
198
+
199
+ // Drives RunLoopObserverManager::onRender, without which no event an emitter
200
+ // produces ever reaches JavaScript.
201
+ GSource *runLoopObserver{nullptr};
202
+
203
+ std::string bundlePath;
204
+ // Empty means the bundle is a raw Fabric script rather than a React app; see
205
+ // startSurface below for what that changes.
206
+ std::string moduleName;
207
+ // Metro's entry point, without the extension. Only meaningful in dev mode:
208
+ // DevServerHelper builds "http://host:port/<sourcePath>.bundle?..." from it,
209
+ // and returns an empty URL if it is unset, which silently falls back to the
210
+ // on-disk bundle.
211
+ std::string sourcePath;
212
+ bool surfaceStarted{false};
213
+ // Whether this run is a development one, which is the only thing that
214
+ // decides whether Ctrl+D opens anything. Kept because the ReactInstanceConfig
215
+ // it came from is not.
216
+ bool devMode{false};
217
+ // Read by main() after the loop ends. GApplication has no exit status to set
218
+ // any more, and a startup that failed has to be tellable from one that ran.
219
+ int exitStatus{0};
220
+ };
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Logging
224
+ // ---------------------------------------------------------------------------
225
+
226
+ // ReactHost's Logger is where console.log and RN's own logging come out.
227
+ void logToGlib(const std::string &message, unsigned int logLevel) {
228
+ switch (logLevel) {
229
+ case ReactNativeLogLevelFatal:
230
+ case ReactNativeLogLevelError:
231
+ g_warning("[js] %s", message.c_str());
232
+ break;
233
+ case ReactNativeLogLevelWarning:
234
+ g_message("[js warn] %s", message.c_str());
235
+ break;
236
+ default:
237
+ g_message("[js] %s", message.c_str());
238
+ break;
239
+ }
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Layout constraints
244
+ // ---------------------------------------------------------------------------
245
+
246
+ // Minimum == maximum pins the root to the window's content box, which is what
247
+ // a full-window surface wants: the root fills, and flex children divide it.
248
+ // A surface that sized itself to its content would leave minimumSize at zero.
249
+ LayoutConstraints constraintsFor(int width, int height) {
250
+ const Size size{.width = static_cast<Float>(width), .height = static_cast<Float>(height)};
251
+ return LayoutConstraints{
252
+ .minimumSize = size,
253
+ .maximumSize = size,
254
+ .layoutDirection = LayoutDirection::LeftToRight,
255
+ };
256
+ }
257
+
258
+ LayoutContext layoutContextFor(int scaleFactor) {
259
+ return LayoutContext{.pointScaleFactor = static_cast<Float>(scaleFactor)};
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Calling into JS
264
+ // ---------------------------------------------------------------------------
265
+
266
+ // Runs on the JS thread. `step` lets the script commit a second, different tree
267
+ // so an Update/Remove diff can be seen coming out of Fabric rather than only an
268
+ // initial mount.
269
+ void callRenderFunction(Host *host, int step) {
270
+ if (host->reactHost == nullptr) {
271
+ return;
272
+ }
273
+ host->reactHost->runOnRuntimeScheduler([step](facebook::jsi::Runtime &runtime) {
274
+ try {
275
+ auto value = runtime.global().getProperty(runtime, kRenderFunctionName);
276
+ if (!value.isObject() || !value.getObject(runtime).isFunction(runtime)) {
277
+ g_warning("script defines no global %s()", kRenderFunctionName);
278
+ return;
279
+ }
280
+ value.getObject(runtime).getFunction(runtime).call(
281
+ runtime,
282
+ facebook::jsi::Value(static_cast<int>(kSurfaceId)),
283
+ facebook::jsi::Value(step));
284
+ } catch (const facebook::jsi::JSError &error) {
285
+ g_warning("%s() threw: %s\n%s",
286
+ kRenderFunctionName,
287
+ error.getMessage().c_str(),
288
+ error.getStack().c_str());
289
+ } catch (const std::exception &error) {
290
+ g_warning("%s() failed: %s", kRenderFunctionName, error.what());
291
+ }
292
+ });
293
+ }
294
+
295
+ // Quitting on a timer is the only way to exercise the shutdown path in
296
+ // automation: the window cannot be closed from a script, and killing the
297
+ // process skips GApplication::shutdown entirely, so stopAllSurfaces and the
298
+ // teardown ordering below it would never run under test. Opt in with
299
+ // BASALT_QUIT_AFTER_MS.
300
+ gboolean quitAfterTimeout(gpointer data) {
301
+ auto *app = static_cast<GApplication *>(data);
302
+ g_message("BASALT_QUIT_AFTER_MS elapsed; quitting");
303
+ g_application_quit(app);
304
+ return G_SOURCE_REMOVE;
305
+ }
306
+
307
+ // The same quit, when the harness asks for it rather than on a budget fixed
308
+ // before the process started. `g_application_quit` either way, so `onShutdown`
309
+ // runs and the widget tree is dumped -- which is the whole point of asking
310
+ // rather than killing. See core/TestQuitFile.h.
311
+ gboolean quitWhenTestFileAppears(gpointer data) {
312
+ // Two instruments, one poll, because they want the same thing at different
313
+ // moments: BASALT_TEST_QUIT_FILE is the harness deciding, and
314
+ // BASALT_QUIT_WHEN_SETTLED is the app noticing it has nothing left to do. A
315
+ // second timer would be a second id to collide and a second place to forget
316
+ // the shutdown ordering.
317
+ const char *why = nullptr;
318
+ if (basalt::testQuitFileAppeared()) {
319
+ why = basalt::kTestQuitFileMessage;
320
+ } else if (basalt::hasSettled()) {
321
+ why = basalt::kQuitWhenSettledMessage;
322
+ } else {
323
+ return G_SOURCE_CONTINUE;
324
+ }
325
+ auto *app = static_cast<GApplication *>(data);
326
+ g_message("%s", why);
327
+ g_application_quit(app);
328
+ return G_SOURCE_REMOVE;
329
+ }
330
+
331
+ // Same clean exit, on SIGINT or SIGTERM, so Ctrl-C and `kill` shut the runtime
332
+ // down instead of dropping it. The default disposition would kill the process
333
+ // where it stands, skipping GApplication::shutdown and everything under it --
334
+ // stopAllSurfaces, the teardown ordering, and the widget tree dump.
335
+ //
336
+ // g_unix_signal_add is the safe form: it does not run in the signal handler,
337
+ // it wakes the main loop and dispatches from there, so ordinary GLib calls are
338
+ // allowed here.
339
+ gboolean quitOnSignal(gpointer data) {
340
+ auto *app = static_cast<GApplication *>(data);
341
+ g_message("signal received; quitting");
342
+ g_application_quit(app);
343
+ return G_SOURCE_REMOVE;
344
+ }
345
+
346
+ // BASALT_TEST_TAP: "x,y" pairs separated by ';', each fired a second apart.
347
+ // See GtkTouchDispatcher::synthesiseTap for why this exists.
348
+ //
349
+ // A point may name a window: "x,y@3" taps in the window whose surface is 3
350
+ // rather than in the app's own. Without it there would be no way to reach a
351
+ // second window at all -- each has its own touch dispatcher, which is the whole
352
+ // point of them, and the main window's would happily hit-test a tree that is
353
+ // not on screen.
354
+ //
355
+ // BASALT_TEST_SECONDARY_TAP takes the same spec and clicks the other button.
356
+ // Its own variable rather than a suffix on a point, because the two assert
357
+ // opposite things -- one presses what it lands on and the other must not -- and
358
+ // a scenario that mixed them in one string would be harder to read than to
359
+ // write.
360
+ struct PendingTap {
361
+ Host *host;
362
+ double x;
363
+ double y;
364
+ facebook::react::SurfaceId surfaceId;
365
+ basalt::PointerButton button;
366
+ };
367
+
368
+ gboolean fireTestTap(gpointer data) {
369
+ std::unique_ptr<PendingTap> tap{static_cast<PendingTap *>(data)};
370
+ const bool secondary = tap->button != basalt::PointerButton::Primary;
371
+ g_message("%s: tapping (%.0f, %.0f) in window %d",
372
+ secondary ? "BASALT_TEST_SECONDARY_TAP" : "BASALT_TEST_TAP",
373
+ tap->x,
374
+ tap->y,
375
+ static_cast<int>(tap->surfaceId));
376
+ HostWindow *target = tap->host->windowFor(tap->surfaceId);
377
+ if (target == nullptr) {
378
+ g_warning("BASALT_TEST_TAP: no window %d", static_cast<int>(tap->surfaceId));
379
+ return G_SOURCE_REMOVE;
380
+ }
381
+ if (target->touchDispatcher != nullptr) {
382
+ target->touchDispatcher->synthesiseTap(tap->x, tap->y, tap->button);
383
+ }
384
+ return G_SOURCE_REMOVE;
385
+ }
386
+
387
+ // Splits "x,y@surface" into its point and its window. The window defaults to
388
+ // the app's own, so every spec written before windows existed still means what
389
+ // it did.
390
+ void parseTestPoint(const char *text,
391
+ double *x,
392
+ double *y,
393
+ facebook::react::SurfaceId *surfaceId) {
394
+ *surfaceId = kSurfaceId;
395
+ char **halves = g_strsplit(text, "@", 2);
396
+ if (halves[1] != nullptr) {
397
+ *surfaceId = static_cast<facebook::react::SurfaceId>(g_ascii_strtoll(halves[1], nullptr, 10));
398
+ }
399
+ char **parts = g_strsplit(halves[0], ",", 2);
400
+ *x = parts[0] != nullptr ? g_ascii_strtod(parts[0], nullptr) : 0.0;
401
+ *y = parts[1] != nullptr ? g_ascii_strtod(parts[1], nullptr) : 0.0;
402
+ const bool complete = parts[0] != nullptr && parts[1] != nullptr;
403
+ g_strfreev(parts);
404
+ g_strfreev(halves);
405
+ if (!complete) {
406
+ *x = -1.0;
407
+ *y = -1.0;
408
+ }
409
+ }
410
+
411
+ // Returns the delay after the last tap, so typing can be scheduled behind it.
412
+ guint scheduleTestTaps(Host *host,
413
+ const char *spec,
414
+ guint delayMs,
415
+ basalt::PointerButton button) {
416
+ char **points = g_strsplit(spec, ";", -1);
417
+ for (char **point = points; *point != nullptr; ++point) {
418
+ if (**point == '\0') {
419
+ continue;
420
+ }
421
+ double x = 0.0;
422
+ double y = 0.0;
423
+ facebook::react::SurfaceId surfaceId = kSurfaceId;
424
+ parseTestPoint(*point, &x, &y, &surfaceId);
425
+ if (x >= 0.0 && y >= 0.0) {
426
+ g_timeout_add(delayMs, fireTestTap, new PendingTap{host, x, y, surfaceId, button});
427
+ delayMs += 1000;
428
+ }
429
+ }
430
+ g_strfreev(points);
431
+ return delayMs;
432
+ }
433
+
434
+ // BASALT_TEST_HOVER: "x,y" points separated by ';', each fired a second apart.
435
+ // The pointer moving with no button down, which is the only way to reach the
436
+ // hover path from automation: a real cursor move needs a seat with input
437
+ // devices, and the whole point of these instruments is a run that has none. A
438
+ // negative point means the pointer left the surface.
439
+ //
440
+ // Reuses PendingTap: the payload is the same, and a second struct with the same
441
+ // three fields would be two things to keep in step.
442
+ gboolean fireTestHover(gpointer data) {
443
+ std::unique_ptr<PendingTap> hover{static_cast<PendingTap *>(data)};
444
+ g_message("BASALT_TEST_HOVER: hovering (%.0f, %.0f)", hover->x, hover->y);
445
+ if (hover->host->main().touchDispatcher != nullptr) {
446
+ hover->host->main().touchDispatcher->synthesiseHover(hover->x, hover->y);
447
+ }
448
+ return G_SOURCE_REMOVE;
449
+ }
450
+
451
+ // Returns the delay after the last point.
452
+ guint scheduleTestHovers(Host *host, const char *spec, guint delayMs) {
453
+ char **points = g_strsplit(spec, ";", -1);
454
+ for (char **point = points; *point != nullptr; ++point) {
455
+ char **parts = g_strsplit(*point, ",", 2);
456
+ if (parts[0] != nullptr && parts[1] != nullptr) {
457
+ g_timeout_add(delayMs,
458
+ fireTestHover,
459
+ new PendingTap{host,
460
+ g_ascii_strtod(parts[0], nullptr),
461
+ g_ascii_strtod(parts[1], nullptr),
462
+ // Hover has no per-window form: the pointer
463
+ // is one thing, and a second window's hover
464
+ // is a feature nothing has asked for yet.
465
+ kSurfaceId,
466
+ // Nor a button: a hovering pointer presses
467
+ // nothing, and this field is only read by
468
+ // the tap path.
469
+ basalt::PointerButton::Primary});
470
+ delayMs += 1000;
471
+ }
472
+ g_strfreev(parts);
473
+ }
474
+ g_strfreev(points);
475
+ return delayMs;
476
+ }
477
+
478
+ // BASALT_TEST_FOCUS: keyboard actions separated by ';' -- `tab`, `shift-tab`,
479
+ // `activate`, `escape` and `devmenu`, each fired a second apart.
480
+ //
481
+ // The same reason the other instruments exist, one step further out. A real Tab
482
+ // needs a window the display server considers focused, and an automated run on
483
+ // a headless compositor has no such thing: the key event is delivered to
484
+ // nothing. This enters at GtkFocusManager, so it exercises GTK's own focus
485
+ // chain, the focus and blur events and the click dispatch, and skips only the
486
+ // delivery of the keystroke itself.
487
+ struct PendingFocus {
488
+ Host *host;
489
+ std::string action;
490
+ };
491
+
492
+ gboolean fireTestFocus(gpointer data) {
493
+ std::unique_ptr<PendingFocus> pending{static_cast<PendingFocus *>(data)};
494
+ g_message("BASALT_TEST_FOCUS: %s", pending->action.c_str());
495
+ if (pending->host->main().focusManager == nullptr) {
496
+ return G_SOURCE_REMOVE;
497
+ }
498
+ if (pending->action == "tab") {
499
+ pending->host->main().focusManager->moveFocus(true);
500
+ } else if (pending->action == "shift-tab") {
501
+ pending->host->main().focusManager->moveFocus(false);
502
+ } else if (pending->action == "activate") {
503
+ pending->host->main().focusManager->activateFocused();
504
+ } else if (pending->action == "devmenu") {
505
+ // Also not a focus action. Same instrument for the same reason: a key that
506
+ // needs nothing focused to arrive. What it opens is answered by
507
+ // BASALT_TEST_MENU; see core/TestDialog.h.
508
+ basalt::showDevMenu(pending->host->reactHost.get());
509
+ } else if (pending->action == "escape") {
510
+ // Not a focus action, and here anyway: this is the instrument for "a key
511
+ // was pressed and nothing on the window has to be focused for it to
512
+ // arrive", which is exactly what Escape closing a <Modal> is.
513
+ pending->host->mountingManager->requestCloseTopModal();
514
+ } else {
515
+ g_warning("BASALT_TEST_FOCUS: unknown action \"%s\"", pending->action.c_str());
516
+ }
517
+ return G_SOURCE_REMOVE;
518
+ }
519
+
520
+ guint scheduleTestFocus(Host *host, const char *spec, guint delayMs) {
521
+ char **actions = g_strsplit(spec, ";", -1);
522
+ for (char **action = actions; *action != nullptr; ++action) {
523
+ if (**action == '\0') {
524
+ continue;
525
+ }
526
+ g_timeout_add(delayMs, fireTestFocus, new PendingFocus{host, *action});
527
+ delayMs += 1000;
528
+ }
529
+ g_strfreev(actions);
530
+ return delayMs;
531
+ }
532
+
533
+ // BASALT_TEST_SCROLL: "x,y,lines" triples separated by ';' -- a wheel over a
534
+ // point, a second apart, in surface-root coordinates. Positive lines scroll
535
+ // down, which is the direction `contentOffset` reads.
536
+ //
537
+ // The AppKit and Win32 hosts have had this since their scroll views did; this
538
+ // is the third, and it arrived with <RefreshControl>, whose gesture is a wheel
539
+ // that keeps asking to go up after the list has already reached its top.
540
+ struct PendingScroll {
541
+ Host *host;
542
+ double x;
543
+ double y;
544
+ double lines;
545
+ };
546
+
547
+ gboolean fireTestScroll(gpointer data) {
548
+ std::unique_ptr<PendingScroll> pending{static_cast<PendingScroll *>(data)};
549
+ Host *host = pending->host;
550
+ if (host->mountingManager == nullptr || host->main().root == nullptr) {
551
+ return G_SOURCE_REMOVE;
552
+ }
553
+ g_message("BASALT_TEST_SCROLL: %g lines at (%g, %g)", pending->lines, pending->x, pending->y);
554
+ // Notches into pixels with the same constant a real wheel uses, so the
555
+ // instrument and the hardware move a list by the same distance.
556
+ host->mountingManager->scrollViews().scrollAt(
557
+ host->main().root, pending->x, pending->y, 0.0, pending->lines * basalt::GtkScrollViewManager::kWheelStepPixels);
558
+ return G_SOURCE_REMOVE;
559
+ }
560
+
561
+ guint scheduleTestScrolls(Host *host, const char *spec, guint delayMs) {
562
+ char **steps = g_strsplit(spec, ";", -1);
563
+ for (char **step = steps; *step != nullptr; ++step) {
564
+ char **parts = g_strsplit(*step, ",", -1);
565
+ if (g_strv_length(parts) == 3) {
566
+ g_timeout_add(delayMs,
567
+ fireTestScroll,
568
+ new PendingScroll{host,
569
+ g_ascii_strtod(parts[0], nullptr),
570
+ g_ascii_strtod(parts[1], nullptr),
571
+ g_ascii_strtod(parts[2], nullptr)});
572
+ delayMs += 1000;
573
+ }
574
+ g_strfreev(parts);
575
+ }
576
+ g_strfreev(steps);
577
+ return delayMs;
578
+ }
579
+
580
+ // BASALT_TEST_DRAG: "x1,y1,x2,y2" -- one press, twenty moves and a release, for
581
+ // the gestures a tap cannot reach. See GtkTouchDispatcher::synthesiseDrag.
582
+ struct PendingDrag {
583
+ Host *host;
584
+ double fromX;
585
+ double fromY;
586
+ double toX;
587
+ double toY;
588
+ };
589
+
590
+ gboolean fireTestDrag(gpointer data) {
591
+ std::unique_ptr<PendingDrag> drag{static_cast<PendingDrag *>(data)};
592
+ g_message("BASALT_TEST_DRAG: (%.0f, %.0f) -> (%.0f, %.0f)",
593
+ drag->fromX,
594
+ drag->fromY,
595
+ drag->toX,
596
+ drag->toY);
597
+ if (drag->host->main().touchDispatcher != nullptr) {
598
+ drag->host->main().touchDispatcher->synthesiseDrag(
599
+ drag->fromX, drag->fromY, drag->toX, drag->toY, 20);
600
+ }
601
+ return G_SOURCE_REMOVE;
602
+ }
603
+
604
+ void scheduleTestDrag(Host *host, const char *spec, guint delayMs) {
605
+ char **parts = g_strsplit(spec, ",", 4);
606
+ if (g_strv_length(parts) == 4) {
607
+ g_timeout_add(delayMs,
608
+ fireTestDrag,
609
+ new PendingDrag{host,
610
+ g_ascii_strtod(parts[0], nullptr),
611
+ g_ascii_strtod(parts[1], nullptr),
612
+ g_ascii_strtod(parts[2], nullptr),
613
+ g_ascii_strtod(parts[3], nullptr)});
614
+ }
615
+ g_strfreev(parts);
616
+ }
617
+
618
+ // BASALT_TEST_TYPE: text to insert into whatever field has focus, for the
619
+ // same reason BASALT_TEST_TAP exists -- macOS cannot synthesise a real key
620
+ // event without accessibility permission an automated run does not have.
621
+ //
622
+ // It inserts through GtkEditable rather than through GDK, so it skips the key
623
+ // controller and the input method and exercises everything above them: the
624
+ // changed signal, the event emitter, React's re-render, and the controlled
625
+ // value coming back down. On Linux the end-to-end suite types with xdotool
626
+ // instead, which does go through GDK.
627
+ struct PendingType {
628
+ Host *host;
629
+ std::string text;
630
+ };
631
+
632
+ // BASALT_TEST_KEY's, for the same reason: a timeout carries one pointer and this
633
+ // needs the host as well as the press.
634
+ struct PendingKey {
635
+ Host *host;
636
+ basalt::KeyCombination pressed;
637
+ };
638
+
639
+ gboolean fireTestKey(gpointer data) {
640
+ auto *pending = static_cast<PendingKey *>(data);
641
+ g_message("BASALT_TEST_KEY: %s", pending->pressed.key.c_str());
642
+ if (pending->host->main().focusManager != nullptr) {
643
+ pending->host->main().focusManager->deliverTestKey(pending->pressed);
644
+ }
645
+ delete pending;
646
+ return G_SOURCE_REMOVE;
647
+ }
648
+
649
+ gboolean fireTestType(gpointer data) {
650
+ std::unique_ptr<PendingType> pending{static_cast<PendingType *>(data)};
651
+ g_message("BASALT_TEST_TYPE: typing \"%s\"", pending->text.c_str());
652
+
653
+ GtkWidget *focus = pending->host->main().window != nullptr
654
+ ? gtk_window_get_focus(pending->host->main().window)
655
+ : nullptr;
656
+ if (focus == nullptr || !GTK_IS_TEXT(focus)) {
657
+ g_warning("BASALT_TEST_TYPE: no text field has focus");
658
+ return G_SOURCE_REMOVE;
659
+ }
660
+
661
+ int position = -1;
662
+ gtk_editable_insert_text(GTK_EDITABLE(focus),
663
+ pending->text.c_str(),
664
+ static_cast<int>(pending->text.size()),
665
+ &position);
666
+ return G_SOURCE_REMOVE;
667
+ }
668
+
669
+ gboolean commitSecondTree(gpointer data) {
670
+ auto *host = static_cast<Host *>(data);
671
+ g_message("--- committing tree 2 from JS ---");
672
+ callRenderFunction(host, 2);
673
+ return G_SOURCE_REMOVE;
674
+ }
675
+
676
+ // Ctrl+D: React Native's developer menu.
677
+ //
678
+ // The shortcut is React Native's own on a simulator, which is the closest
679
+ // thing a desktop has to a phone's shake. It does nothing in a release run --
680
+ // there is no dev server to reload from and no debugger to open -- so the key
681
+ // is left to travel on rather than swallowed.
682
+ gboolean onDevMenuKey(GtkEventControllerKey * /*controller*/,
683
+ guint keyval,
684
+ guint /*keycode*/,
685
+ GdkModifierType state,
686
+ gpointer data) {
687
+ auto *host = static_cast<Host *>(data);
688
+ if (!host->devMode || host->reactHost == nullptr) {
689
+ return GDK_EVENT_PROPAGATE;
690
+ }
691
+ if ((keyval != GDK_KEY_d && keyval != GDK_KEY_D) ||
692
+ (state & GDK_CONTROL_MASK) == 0) {
693
+ return GDK_EVENT_PROPAGATE;
694
+ }
695
+ basalt::showDevMenu(host->reactHost.get());
696
+ return GDK_EVENT_STOP;
697
+ }
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // Window size -> surface constraints
701
+ // ---------------------------------------------------------------------------
702
+
703
+ // Per window: `data` is the HostWindow whose root resized, not the Host.
704
+ //
705
+ // Which is the whole of what multiple windows changes here. Constraining
706
+ // `kSurfaceId` from every window would lay the *first* window's tree out to the
707
+ // second window's size, and the symptom would be a first window whose content
708
+ // jumped whenever a second one was dragged.
709
+ void onRootResized(RnView * /*view*/, int width, int height, gpointer data) {
710
+ auto *made = static_cast<HostWindow *>(data);
711
+ Host *host = made->host;
712
+ if (width <= 0 || height <= 0 || !host->surfaceStarted) {
713
+ return;
714
+ }
715
+ g_debug("surface %d constraints -> %dx%d", static_cast<int>(made->surfaceId), width, height);
716
+ host->reactHost->setSurfaceConstraints(
717
+ made->surfaceId, constraintsFor(width, height), layoutContextFor(made->scaleFactor));
718
+
719
+ // A <Modal> is sized from its own shadow-node state rather than from a style,
720
+ // and React Native's C++ platform answers "what size is the screen" with
721
+ // zero. Told here, where the window's size is already being handed to Fabric.
722
+ host->mountingManager->setSurfaceSize(static_cast<float>(width), static_cast<float>(height));
723
+ // The window moved or was resized, which `useWindow()` hands an app as live
724
+ // bounds. Here rather than in a watcher of its own: this is already the one
725
+ // place that learns it.
726
+ basalt::notifyWindowBoundsChanged();
727
+
728
+ // The inspector covers the main window, so it resizes with it -- and only
729
+ // with it: an error is about the app rather than about a window, and it is
730
+ // shown in the one the app started in.
731
+ if (host->logBoxRoot != nullptr && made->surfaceId == kSurfaceId) {
732
+ rn_view_set_frame(host->logBoxRoot, 0, 0, static_cast<float>(width), static_cast<float>(height));
733
+ host->reactHost->setSurfaceConstraints(
734
+ kLogBoxSurfaceId, constraintsFor(width, height), layoutContextFor(made->scaleFactor));
735
+ }
736
+ }
737
+
738
+ // Starts or stops React Native's error inspector.
739
+ //
740
+ // It is `LogBoxInspectorContainer`, registered by AppRegistry under the name
741
+ // "LogBox" exactly as an app registers its own component -- so this is a second
742
+ // surface rather than an overlay this host draws, and everything in it is React
743
+ // Native's own JavaScript. See core/LogBoxSurface.h.
744
+ //
745
+ // Started and stopped rather than kept and hidden: a surface that exists is a
746
+ // React tree that renders and re-renders, and the inspector is showing for a
747
+ // vanishingly small part of a session.
748
+ void hideLogBoxSurface(Host *host) {
749
+ if (host->reactHost == nullptr || host->logBoxRoot == nullptr) {
750
+ return;
751
+ }
752
+ host->reactHost->stopSurface(kLogBoxSurfaceId);
753
+ gtk_overlay_remove_overlay(GTK_OVERLAY(host->main().overlay), GTK_WIDGET(host->logBoxRoot));
754
+ host->mountingManager->destroySurfaceRoot(kLogBoxSurfaceId);
755
+ host->logBoxRoot = nullptr;
756
+ }
757
+
758
+ void showLogBoxSurface(Host *host, const std::string &appKey) {
759
+ if (host->reactHost == nullptr || host->main().overlay == nullptr || host->logBoxRoot != nullptr) {
760
+ return;
761
+ }
762
+
763
+ const int width = gtk_widget_get_width(GTK_WIDGET(host->main().root));
764
+ const int height = gtk_widget_get_height(GTK_WIDGET(host->main().root));
765
+ if (width <= 0 || height <= 0) {
766
+ return;
767
+ }
768
+
769
+ host->logBoxRoot = host->mountingManager->createSurfaceRoot(kLogBoxSurfaceId);
770
+ rn_view_set_frame(host->logBoxRoot, 0, 0, static_cast<float>(width), static_cast<float>(height));
771
+ // Added after the window controls, so it is above them: an error box that a
772
+ // close button sits on top of is one the user can close by accident.
773
+ gtk_widget_set_halign(GTK_WIDGET(host->logBoxRoot), GTK_ALIGN_FILL);
774
+ gtk_widget_set_valign(GTK_WIDGET(host->logBoxRoot), GTK_ALIGN_FILL);
775
+ gtk_overlay_add_overlay(GTK_OVERLAY(host->main().overlay), GTK_WIDGET(host->logBoxRoot));
776
+
777
+ host->reactHost->startSurface(kLogBoxSurfaceId,
778
+ appKey,
779
+ folly::dynamic::object(),
780
+ constraintsFor(width, height),
781
+ layoutContextFor(host->main().scaleFactor));
782
+ }
783
+
784
+ // The frame clock only exists once a widget is realised, so the choreographer
785
+ // cannot be attached at construction time.
786
+ void onRootMapped(GtkWidget *widget, gpointer data) {
787
+ auto *host = static_cast<Host *>(data);
788
+ if (host->choreographer != nullptr) {
789
+ host->choreographer->attachToWidget(widget);
790
+ }
791
+ }
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // Startup
795
+ // ---------------------------------------------------------------------------
796
+
797
+ // Feature flags this platform states explicitly. Anything not listed here keeps
798
+ // React Native's own default, which is what the base class provides.
799
+ class LinuxFeatureFlags : public facebook::react::ReactNativeFeatureFlagsDefaults {
800
+ public:
801
+ bool enableBridgelessArchitecture() override {
802
+ return true;
803
+ }
804
+ };
805
+
806
+ // TurboModules this platform supplies itself. ReactCxxTurboModuleProvider
807
+ // consults these before its own, so naming a module it also provides replaces
808
+ // it. Today that is only PlatformConstants; this is also the seam an Expo port
809
+ // would use.
810
+ facebook::react::TurboModuleProviders makeTurboModuleProviders(
811
+ std::string scriptURL,
812
+ bool devMode,
813
+ std::weak_ptr<basalt::GtkImageLoader> imageLoader) {
814
+ facebook::react::TurboModuleProviders providers;
815
+ // Whatever the discovered capability packages supply, first: a package naming
816
+ // a module this host also provides is replacing it deliberately, and the same
817
+ // rule already applies to this list against ReactCxxPlatform's own. Answers
818
+ // nullptr for every name when no package supplies one, which is most builds.
819
+ // See core/PackageModules.h.
820
+ providers.emplace_back(basalt::packageTurboModule);
821
+ providers.emplace_back(
822
+ [scriptURL = std::move(scriptURL), devMode, imageLoader = std::move(imageLoader)](
823
+ const std::string &name,
824
+ const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker)
825
+ -> std::shared_ptr<facebook::react::TurboModule> {
826
+ if (name == facebook::react::PlatformConstantsModule::kModuleName) {
827
+ return std::make_shared<basalt::DesktopPlatformConstantsModule>(jsInvoker);
828
+ }
829
+ // `Image.getSize` and `Image.prefetch`. Upstream builds this module
830
+ // with no loader at all and offers no way to supply one, so the answer
831
+ // is to build it here with the loader this host already has -- which
832
+ // is also the one holding the decoded images, so a size asked for
833
+ // something on screen costs nothing. See docs/backlog/upstream.md.
834
+ if (name == facebook::react::ImageLoaderModule::kModuleName) {
835
+ return std::make_shared<facebook::react::ImageLoaderModule>(jsInvoker, imageLoader);
836
+ }
837
+ if (name == basalt::DesktopAppModule::kModuleName) {
838
+ return std::make_shared<basalt::DesktopAppModule>(jsInvoker);
839
+ }
840
+ if (name == basalt::DesktopAppearanceModule::kModuleName) {
841
+ return std::make_shared<basalt::DesktopAppearanceModule>(jsInvoker);
842
+ }
843
+ #ifdef BASALT_HAS_SKIA
844
+ // Skia, when the build was pointed at an app that has it. Its
845
+ // `install` is what puts Skia's whole JavaScript API on the global
846
+ // object, and an app that imports Skia does not start without it;
847
+ // see gtk/GtkSkiaModule.h.
848
+ if (name == basalt::GtkSkiaModule::kModuleName) {
849
+ return std::make_shared<basalt::GtkSkiaModule>(jsInvoker);
850
+ }
851
+ #endif
852
+ if (name == basalt::DesktopBlobModule::kModuleName) {
853
+ return std::make_shared<basalt::DesktopBlobModule>(jsInvoker);
854
+ }
855
+ if (name == basalt::DesktopFileReaderModule::kModuleName) {
856
+ return std::make_shared<basalt::DesktopFileReaderModule>(jsInvoker);
857
+ }
858
+ if (name == basalt::DesktopClipboardModule::kModuleName) {
859
+ return std::make_shared<basalt::DesktopClipboardModule>(jsInvoker);
860
+ }
861
+ if (name == basalt::DesktopVibrationModule::kModuleName) {
862
+ return std::make_shared<basalt::DesktopVibrationModule>(jsInvoker);
863
+ }
864
+ if (name == basalt::DesktopAlertModule::kModuleName) {
865
+ return std::make_shared<basalt::DesktopAlertModule>(jsInvoker);
866
+ }
867
+ if (name == basalt::DesktopLinkingModule::kModuleName) {
868
+ return std::make_shared<basalt::DesktopLinkingModule>(jsInvoker);
869
+ }
870
+ if (name == basalt::DesktopShareModule::kModuleName) {
871
+ return std::make_shared<basalt::DesktopShareModule>(jsInvoker);
872
+ }
873
+ // The native file dialogs, which React Native has no API for and which
874
+ // are the first thing a desktop app reaches for. See core/DialogModule.h.
875
+ if (name == basalt::DesktopDialogModule::kModuleName) {
876
+ return std::make_shared<basalt::DesktopDialogModule>(jsInvoker);
877
+ }
878
+ // The application menu, which on macOS is also what makes Cmd-C reach
879
+ // a text field. See core/MenuModel.h.
880
+ if (name == basalt::DesktopMenuModule::kModuleName) {
881
+ return std::make_shared<basalt::DesktopMenuModule>(jsInvoker);
882
+ }
883
+ // More than one window, which is more than one surface. See
884
+ // core/WindowHost.h.
885
+ if (name == basalt::DesktopWindowsModule::kModuleName) {
886
+ return std::make_shared<basalt::DesktopWindowsModule>(jsInvoker);
887
+ }
888
+ if (name == basalt::DesktopI18nManagerModule::kModuleName) {
889
+ return std::make_shared<basalt::DesktopI18nManagerModule>(jsInvoker);
890
+ }
891
+ if (name == basalt::DesktopAccessibilityManagerModule::kModuleName) {
892
+ return std::make_shared<basalt::DesktopAccessibilityManagerModule>(jsInvoker);
893
+ }
894
+ if (name == basalt::DesktopAccessibilityInfoModule::kModuleName) {
895
+ return std::make_shared<basalt::DesktopAccessibilityInfoModule>(jsInvoker);
896
+ }
897
+ if (name == basalt::DesktopToastModule::kModuleName) {
898
+ return std::make_shared<basalt::DesktopToastModule>(jsInvoker);
899
+ }
900
+ // react-native-gesture-handler's module. Offered whether or not the app
901
+ // has the library: it costs a name comparison, and the alternative is a
902
+ // host that has to be rebuilt to run an app that uses gestures.
903
+ if (name == basalt::DesktopGestureHandlerModule::kModuleName) {
904
+ return std::make_shared<basalt::DesktopGestureHandlerModule>(jsInvoker);
905
+ }
906
+ #ifdef BASALT_HAS_WORKLETS
907
+ // react-native-worklets, when the build was pointed at one. Reanimated
908
+ // is built on it; see core/WorkletsModule.h.
909
+ if (name == basalt::DesktopWorkletsModule::kModuleName) {
910
+ return std::make_shared<basalt::DesktopWorkletsModule>(jsInvoker);
911
+ }
912
+ #endif
913
+ #ifdef BASALT_HAS_REANIMATED
914
+ if (name == basalt::DesktopReanimatedModule::kModuleName) {
915
+ return std::make_shared<basalt::DesktopReanimatedModule>(jsInvoker);
916
+ }
917
+ #endif
918
+ // Only outside dev mode. ReactCxxPlatform provides a real DevSettings
919
+ // when a dev server exists, and this provider is consulted first -- so
920
+ // offering ours unconditionally would shadow the one that works.
921
+ if (!devMode && name == basalt::DesktopDevSettingsModule::kModuleName) {
922
+ return std::make_shared<basalt::DesktopDevSettingsModule>(jsInvoker);
923
+ }
924
+ if (name == basalt::DesktopStatusBarModule::kModuleName) {
925
+ return std::make_shared<basalt::DesktopStatusBarModule>(jsInvoker);
926
+ }
927
+ // The title bar. Reaches the window through the one GtkTitleBar, which
928
+ // the host attached at startup -- a module cannot be handed a window it
929
+ // is created before.
930
+ if (name == basalt::GtkWindowModule::kModuleName) {
931
+ return std::make_shared<basalt::GtkWindowModule>(jsInvoker);
932
+ }
933
+ // Only outside dev mode, for the same reason DevSettings is above.
934
+ //
935
+ // This module's scriptURL is what HMRClient registers with Metro as its
936
+ // entry point, and Metro resolves that URL to a *graph*. The URL
937
+ // synthesised here is not the one DevServerHelper actually fetched --
938
+ // that one carries lazy, minify, runModule and app -- so it names a
939
+ // graph nobody built, Metro answers GraphNotFoundError, and Fast
940
+ // Refresh silently never starts. ReactCxxPlatform's own SourceCode
941
+ // module reports the URL the bundle really came from, which is the one
942
+ // whose graph exists.
943
+ if (!devMode && name == basalt::DesktopSourceCodeModule::kModuleName) {
944
+ return std::make_shared<basalt::DesktopSourceCodeModule>(jsInvoker, scriptURL);
945
+ }
946
+ return nullptr;
947
+ });
948
+ return providers;
949
+ }
950
+
951
+ // Runs against the JavaScript runtime before the bundle is evaluated, which is
952
+ // the only moment early enough for what goes in here.
953
+ void installBindings(facebook::jsi::Runtime &runtime) {
954
+ // Before Expo's, and before anything else that might look a module up: this is
955
+ // what makes TurboModuleRegistry's first question answerable on a bridgeless
956
+ // runtime. See core/TurboModuleProxy.h.
957
+ basalt::installTurboModuleProxy(runtime);
958
+ basalt::installExpoRuntime(runtime);
959
+ }
960
+
961
+ std::shared_ptr<const ContextContainer> makeContextContainer() {
962
+ auto contextContainer = std::make_shared<ContextContainer>();
963
+
964
+ // ReactHost throws without these two. ReactCxxPlatform's own implementations
965
+ // are real ones -- curl-backed http and a websocket client -- so a host that
966
+ // is not a test harness should use them rather than stubs. The DevTools
967
+ // variants default to the same pair when left unset.
968
+ contextContainer->insert(facebook::react::HttpClientFactoryKey,
969
+ facebook::react::getHttpClientFactory());
970
+ contextContainer->insert(facebook::react::WebSocketClientFactoryKey,
971
+ facebook::react::getWebSocketClientFactory());
972
+
973
+ // MessageQueueThreadFactoryKey is deliberately left unset: ReactHost then
974
+ // installs MessageQueueThreadImpl, a real threaded queue, which is what we
975
+ // want. Fantom overrides it only to make test execution deterministic.
976
+ return contextContainer;
977
+ }
978
+
979
+ // Makes a window, a surface root for it, and the input that drives them.
980
+ //
981
+ // Called for the main window and for every one an app opens afterwards, which
982
+ // is the point: the second window is not a special case of the first, it is the
983
+ // same function with a different surface id. See core/WindowHost.h.
984
+ //
985
+ // The window is not shown and the surface is not started here. The main window
986
+ // waits for the bundle to load and an app's window waits for its component to
987
+ // be named, and both of those are the caller's business.
988
+ HostWindow *createHostWindow(Host *host,
989
+ GtkApplication *app,
990
+ facebook::react::SurfaceId surfaceId,
991
+ const char *title,
992
+ int width,
993
+ int height) {
994
+ // The first window through here is the app's own, and a few things belong to
995
+ // it alone rather than to every window: the title bar, the frame clock the
996
+ // animation choreographer runs on, and the error inspector.
997
+ const bool isMainWindow = host->windows.empty();
998
+
999
+ auto owned = std::make_unique<HostWindow>();
1000
+ HostWindow *made = owned.get();
1001
+ made->surfaceId = surfaceId;
1002
+ made->host = host;
1003
+
1004
+ made->window = GTK_WINDOW(gtk_application_window_new(app));
1005
+ gtk_window_set_title(made->window, title);
1006
+ gtk_window_set_default_size(made->window, width, height);
1007
+ made->scaleFactor = gtk_widget_get_scale_factor(GTK_WIDGET(made->window));
1008
+
1009
+ // Fabric emits no Create for a surface root -- the root shadow node is the
1010
+ // base of every diff, so it has to exist before the surface starts.
1011
+ made->root = host->mountingManager->createSurfaceRoot(surfaceId);
1012
+
1013
+ // The surface root, with room above it for the window controls the host
1014
+ // draws when the title bar is hidden. GTK takes the whole titlebar away with
1015
+ // the decorations -- buttons included -- where Windows and macOS keep
1016
+ // drawing theirs over the app's content, so on this desktop the host has to
1017
+ // put them back. An overlay is how: the root fills the window and the
1018
+ // controls sit over its top corner.
1019
+ made->overlay = gtk_overlay_new();
1020
+ gtk_overlay_set_child(GTK_OVERLAY(made->overlay), GTK_WIDGET(made->root));
1021
+
1022
+ GtkWidget *controls = gtk_window_controls_new(GTK_PACK_END);
1023
+ gtk_widget_set_halign(controls, GTK_ALIGN_END);
1024
+ gtk_widget_set_valign(controls, GTK_ALIGN_START);
1025
+ // Hidden until an app asks for the hidden style; see GtkTitleBar.h.
1026
+ gtk_widget_set_visible(controls, FALSE);
1027
+ gtk_overlay_add_overlay(GTK_OVERLAY(made->overlay), controls);
1028
+
1029
+ gtk_window_set_child(made->window, made->overlay);
1030
+ // The title bar is the main window's. It is a process-wide seam -- one title,
1031
+ // one style -- and making it per-window is its own piece of work; see
1032
+ // docs/BACKLOG.md.
1033
+ if (isMainWindow) {
1034
+ basalt::titleBar().attach(made->window, controls);
1035
+
1036
+ // A press inside a region the app marked with <TitleBar.DragRegion> hands
1037
+ // the window to the window manager's own move loop.
1038
+ //
1039
+ // This is the host's job rather than a method JavaScript calls, because
1040
+ // gdk_toplevel_begin_move needs the device, button and timestamp of the
1041
+ // press it is taking over, and by the time a JS handler could run those
1042
+ // are gone. See GtkTitleBar::startDrag, which says so at more length.
1043
+ //
1044
+ // Windows needs none of this: it answers HTCAPTION from WM_NCHITTEST and
1045
+ // the system does the rest. GTK has no equivalent hook, so the gesture is
1046
+ // the equivalent -- and it asks the same question of the same nativeIDs,
1047
+ // which is what keeps the two desktops agreeing about what drags.
1048
+ GtkGesture *dragRegions = gtk_gesture_click_new();
1049
+ gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(dragRegions), GDK_BUTTON_PRIMARY);
1050
+ // Capture, so this is asked before the app's own views handle the press.
1051
+ // In the bubble phase a <Pressable> inside a drag region would have taken
1052
+ // it already, and the window would never move.
1053
+ gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(dragRegions),
1054
+ GTK_PHASE_CAPTURE);
1055
+ g_signal_connect(
1056
+ dragRegions,
1057
+ "pressed",
1058
+ G_CALLBACK(+[](GtkGestureClick *gesture,
1059
+ int /*nPress*/,
1060
+ double x,
1061
+ double y,
1062
+ gpointer data) {
1063
+ auto *self = static_cast<HostWindow *>(data);
1064
+ if (self == nullptr || self->root == nullptr || self->window == nullptr) {
1065
+ return;
1066
+ }
1067
+ // Only the hidden style. With GTK's own decorations the titlebar is
1068
+ // a real widget above the app, dragging it already works, and an
1069
+ // app's marked region is just a view.
1070
+ if (basalt::titleBar().metrics().style != basalt::TitleBarStyle::Hidden) {
1071
+ return;
1072
+ }
1073
+ if (!basalt::isTitleBarDragRegionAt(self->root, x, y)) {
1074
+ return;
1075
+ }
1076
+
1077
+ GtkEventController *const controller = GTK_EVENT_CONTROLLER(gesture);
1078
+ GdkDevice *const device = gtk_event_controller_get_current_event_device(controller);
1079
+ const guint32 timestamp = gtk_event_controller_get_current_event_time(controller);
1080
+ const int button =
1081
+ static_cast<int>(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
1082
+
1083
+ // The gesture reports the root's coordinates; GDK wants the
1084
+ // surface's. They differ by the root's offset within the window and
1085
+ // again by the surface transform, which is where a compositor puts
1086
+ // client-side shadows -- ignore either and the window jumps by that
1087
+ // much as the drag starts.
1088
+ // compute_point rather than translate_coordinates, which GTK 4.12
1089
+ // deprecated and this build treats as an error. Brace-initialised
1090
+ // because GRAPHENE_POINT_INIT is a C99 compound literal that C++
1091
+ // rejects -- the same trap GtkMountingManager records for
1092
+ // GRAPHENE_SIZE_INIT -- and the result is checked because the
1093
+ // function is warn-unused-result: false means the two widgets share
1094
+ // no ancestry, which a window mid-teardown really can produce.
1095
+ // Assigned field by field rather than brace-initialised, because this
1096
+ // whole lambda is one argument to the G_CALLBACK macro below and the
1097
+ // preprocessor counts commas at paren depth without understanding
1098
+ // braces: `{a, b}` reads as two macro arguments and the expansion
1099
+ // fails with "too many arguments provided to function-like macro".
1100
+ // Empty braces are safe, which is why the same type in
1101
+ // GtkTouchDispatcher.cpp raises nothing.
1102
+ graphene_point_t inRoot{};
1103
+ inRoot.x = static_cast<float>(x);
1104
+ inRoot.y = static_cast<float>(y);
1105
+ graphene_point_t inWindow{};
1106
+ if (!gtk_widget_compute_point(
1107
+ GTK_WIDGET(self->root), GTK_WIDGET(self->window), &inRoot, &inWindow)) {
1108
+ return;
1109
+ }
1110
+ double originX = 0;
1111
+ double originY = 0;
1112
+ gtk_native_get_surface_transform(GTK_NATIVE(self->window), &originX, &originY);
1113
+
1114
+ basalt::titleBar().beginMoveDrag(device,
1115
+ button,
1116
+ inWindow.x + originX,
1117
+ inWindow.y + originY,
1118
+ timestamp);
1119
+
1120
+ // Claimed: the move loop now owns the pointer, and leaving the
1121
+ // sequence unclaimed would also deliver this press to the view under
1122
+ // it once the drag ended.
1123
+ gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED);
1124
+ }),
1125
+ made);
1126
+ gtk_widget_add_controller(GTK_WIDGET(made->root), GTK_EVENT_CONTROLLER(dragRegions));
1127
+ }
1128
+
1129
+ rn_view_set_resize_callback(made->root, onRootResized, made);
1130
+
1131
+ // Maximised and full screen, which change the bounds and not the size of the
1132
+ // surface -- so the resize callback above never runs for them. GTK has no
1133
+ // move notification and no position to report; see GtkWindowControl.cpp.
1134
+ const auto onWindowState = +[](GObject * /*window*/, GParamSpec * /*spec*/, gpointer /*data*/) {
1135
+ basalt::notifyWindowBoundsChanged();
1136
+ };
1137
+ // Somebody is trying to close this window: its own close button, the window
1138
+ // manager, or Alt+F4. Every window, including the app's own -- an app that
1139
+ // wants to ask "are you sure" wants to ask hardest about the one it is
1140
+ // running in.
1141
+ g_signal_connect(made->window,
1142
+ "close-request",
1143
+ G_CALLBACK(+[](GtkWindow *window, gpointer data) -> gboolean {
1144
+ (void)window;
1145
+ auto *closing = static_cast<HostWindow *>(data);
1146
+
1147
+ // Asked to be asked first. Refuse, and tell the app; what
1148
+ // happens next is its decision, and if that is "go ahead"
1149
+ // it calls close() itself. See core/WindowHost.h for why
1150
+ // the answer has to be available before the question.
1151
+ if (basalt::hostWindowCloseIntercepted(closing->surfaceId)) {
1152
+ basalt::hostWindowCloseRequested(closing->surfaceId);
1153
+ return TRUE;
1154
+ }
1155
+
1156
+ // The main window closing ends the process, which
1157
+ // GtkApplication already arranges -- so nothing here has
1158
+ // to, and unhandled is how it gets to.
1159
+ if (closing->surfaceId == kSurfaceId) {
1160
+ return FALSE;
1161
+ }
1162
+
1163
+ // Closed by the person rather than by the app. Without
1164
+ // this the host keeps a record whose GtkWindow is gone --
1165
+ // a dangling pointer rather than a stale flag -- and the
1166
+ // `<Window>` that opened it goes on believing it is open.
1167
+ //
1168
+ // Tell JavaScript, then take the window down the same way
1169
+ // an app closing it would, so there is one teardown path
1170
+ // rather than two.
1171
+ basalt::hostWindowClosed(closing->surfaceId);
1172
+ basalt::closeHostWindow(closing->surfaceId);
1173
+ // Handled: closeHostWindow destroys the window once the
1174
+ // surface has stopped, and letting GTK destroy it now
1175
+ // would be the race that teardown exists to avoid.
1176
+ return TRUE;
1177
+ }),
1178
+ made);
1179
+
1180
+ g_signal_connect(made->window, "notify::maximized", G_CALLBACK(onWindowState), nullptr);
1181
+ g_signal_connect(made->window, "notify::fullscreened", G_CALLBACK(onWindowState), nullptr);
1182
+ // The animation choreographer runs on a frame clock, and a widget only has
1183
+ // one once it is realised -- hence the "map". The *main* window's, and only
1184
+ // its: attaching to every window's meant the last one opened stole the clock
1185
+ // from the first, so animations followed whichever window appeared most
1186
+ // recently and stopped when it closed. It also left a handler connected to a
1187
+ // frame clock that was about to be finalised, which GLib complained about at
1188
+ // shutdown and was how this was noticed at all.
1189
+ if (isMainWindow) {
1190
+ g_signal_connect(made->root, "map", G_CALLBACK(onRootMapped), host);
1191
+ }
1192
+
1193
+ // Input, per window. Attached to this window's root, which is where its hit
1194
+ // testing starts -- a second window with the first one's dispatcher would
1195
+ // deliver every press to the wrong tree.
1196
+ made->touchDispatcher =
1197
+ std::make_unique<basalt::GtkTouchDispatcher>(host->mountingManager.get(), made->root);
1198
+ // The keyboard half: Tab reaching a <Pressable>, and Enter activating it.
1199
+ made->focusManager =
1200
+ std::make_unique<basalt::GtkFocusManager>(host->mountingManager.get(), made->root);
1201
+
1202
+ // Ctrl+D, which is React Native's developer menu. Its own controller rather
1203
+ // than another case in the focus manager's: this has nothing to do with
1204
+ // focus, and a shortcut that works wherever the caret is has to be on the
1205
+ // window in the capture phase anyway -- otherwise a <TextInput> with the
1206
+ // keyboard would eat it.
1207
+ GtkEventController *devKeys = gtk_event_controller_key_new();
1208
+ gtk_event_controller_set_propagation_phase(devKeys, GTK_PHASE_CAPTURE);
1209
+ g_signal_connect(devKeys, "key-pressed", G_CALLBACK(onDevMenuKey), host);
1210
+ gtk_widget_add_controller(GTK_WIDGET(made->window), devKeys);
1211
+
1212
+ host->windows.push_back(std::move(owned));
1213
+ return made;
1214
+ }
1215
+
1216
+ // The host's half of core/WindowHost.h.
1217
+ //
1218
+ // A single global, because a process has one host and one GtkApplication and
1219
+ // the seam takes neither. The alternative -- threading a Host through a
1220
+ // TurboModule -- would mean the module knowing what a Host is.
1221
+ Host *gWindowHost = nullptr;
1222
+
1223
+ void onActivate(GtkApplication *app, gpointer data) {
1224
+ auto *host = static_cast<Host *>(data);
1225
+
1226
+ // Constructed here, on the GTK main thread: GtkMountingManager records this
1227
+ // thread and asserts that every widget mutation lands back on it.
1228
+ host->mountingManager = std::make_shared<basalt::GtkMountingManager>();
1229
+
1230
+ // The application's own window, which is windows.front() from here on and is
1231
+ // what everything not yet per-window means by "the window".
1232
+ host->application = app;
1233
+ gWindowHost = host;
1234
+ createHostWindow(host, app, kSurfaceId, "basalt-core", kInitialWidth, kInitialHeight);
1235
+
1236
+ // Prime the bounds cache, which `getBounds()` answers from: without this an
1237
+ // app's first render sees a window of no size, and only a later resize
1238
+ // corrects it. See core/WindowBoundsCache.cpp.
1239
+ basalt::notifyWindowBoundsChanged();
1240
+
1241
+ // And the display list, for the same reason and off the same seam: an app
1242
+ // asking which screens exist before anything has been plugged or unplugged
1243
+ // should not be told there are none.
1244
+ basalt::notifyDisplaysChanged();
1245
+
1246
+ // Accepting what the desktop drags onto this window. On the surface root
1247
+ // rather than per view: GTK delivers a drop to the controller under the
1248
+ // pointer, and which *app* view that is is core/DragAndDrop.h's question.
1249
+ basalt::attachDropTarget(host->main().root);
1250
+
1251
+ // And dragging out of it. A separate controller on the same widget: GTK
1252
+ // keeps the two directions apart and so does the marker -- a view may be
1253
+ // both, and most are neither.
1254
+ basalt::attachDragSource(host->main().root);
1255
+
1256
+ host->runLoopObserverManager = std::make_shared<RunLoopObserverManager>();
1257
+ host->choreographer = std::make_shared<basalt::GtkAnimationChoreographer>();
1258
+
1259
+ // Light or dark, and any change to it. Before ReactHost, so the module can
1260
+ // answer the first `Appearance.getColorScheme()` a bundle makes, which for an
1261
+ // Expo app is during its first import.
1262
+ basalt::startObservingColorScheme();
1263
+
1264
+ // Before ReactHost, so the beat is being induced from the first event on.
1265
+ host->runLoopObserver = basalt::installRunLoopObserver(host->runLoopObserverManager);
1266
+
1267
+ // Say what this host needs rather than inheriting a default that moves.
1268
+ //
1269
+ // HermesInstance gives the runtime a microtask queue only when
1270
+ // enableBridgelessArchitecture() is true, and React's scheduler enqueues a
1271
+ // microtask on its first render. That flag defaults to true on React Native
1272
+ // `main` and false on 0.87.1, so the same code that works against main throws
1273
+ // "Could not enqueue microtask because they are disabled in this runtime" on
1274
+ // a release, before anything appears on screen. This host is bridgeless --
1275
+ // there is no bridge here to be the alternative -- so it should have been
1276
+ // asserting that all along.
1277
+ //
1278
+ // iOS and Android override feature flags at startup too; relying on the
1279
+ // default was the anomaly.
1280
+ facebook::react::ReactNativeFeatureFlags::override(
1281
+ std::make_unique<LinuxFeatureFlags>());
1282
+
1283
+ ReactInstanceConfig config;
1284
+ // The appId is how this host tells Metro which platform it is. ReactCxxPlatform's
1285
+ // DevServerHelper builds its bundle URL from `constexpr DEFAULT_PLATFORM =
1286
+ // "android"` with no hook and no setting, and `app=` -- which comes from
1287
+ // here, and which Metro itself ignores -- is the only thing in that URL a
1288
+ // host controls. The Metro plugin reads it back and corrects the platform.
1289
+ // See APP_ID_PREFIX in packages/basalt-core/metro-config.js.
1290
+ config.appId = "basalt-linux";
1291
+ config.deviceName = "linux";
1292
+
1293
+ // Dev mode changes three things at once, which is worth being explicit about:
1294
+ // loadScript tries Metro before the on-disk bundle; DevServerHelper exists,
1295
+ // which is the only condition under which ReactCxxTurboModuleProvider serves
1296
+ // the DevSettings module a __DEV__ bundle requires; and ReactHost opens a
1297
+ // packager connection whose reload message reloads the instance.
1298
+ config.enableDevMode = g_getenv("BASALT_DEV") != nullptr;
1299
+ host->devMode = config.enableDevMode;
1300
+ config.enableInspector = config.enableDevMode;
1301
+ if (const char *devHost = g_getenv("BASALT_DEV_HOST")) {
1302
+ config.devServerHost = devHost;
1303
+ }
1304
+ if (const char *devPort = g_getenv("BASALT_DEV_PORT")) {
1305
+ config.devServerPort = static_cast<uint32_t>(g_ascii_strtoull(devPort, nullptr, 10));
1306
+ }
1307
+ if (config.enableDevMode) {
1308
+ g_message("dev mode: Metro at %s:%u, entry '%s'",
1309
+ config.devServerHost.c_str(),
1310
+ config.devServerPort,
1311
+ host->sourcePath.c_str());
1312
+ // So the http client can tell the bundle request apart from an app's own
1313
+ // fetch, and refuse to feed a Metro error page to the JS engine. See
1314
+ // core/DevBundle.h.
1315
+ basalt::setDevServerOrigin(config.devServerHost, config.devServerPort);
1316
+ }
1317
+
1318
+ try {
1319
+ host->reactHost = std::make_unique<ReactHost>(config,
1320
+ host->mountingManager,
1321
+ host->runLoopObserverManager,
1322
+ makeContextContainer(),
1323
+ facebook::react::getDefaultOnJsErrorFunc(),
1324
+ logToGlib,
1325
+ nullptr,
1326
+ makeTurboModuleProviders(
1327
+ basalt::scriptURLFor(
1328
+ host->bundlePath,
1329
+ config.enableDevMode,
1330
+ config.devServerHost,
1331
+ config.devServerPort,
1332
+ host->sourcePath.empty() ? "index"
1333
+ : host->sourcePath,
1334
+ "linux"),
1335
+ config.enableDevMode,
1336
+ host->mountingManager
1337
+ ->imageLoader()),
1338
+ // React Native's error
1339
+ // inspector. ReactCxxPlatform
1340
+ // implements the LogBox
1341
+ // TurboModule itself and only
1342
+ // provides it when a host
1343
+ // hands over one of these;
1344
+ // passing null, as this did,
1345
+ // left NativeLogBox.show() a
1346
+ // call into nothing. See
1347
+ // core/LogBoxSurface.h.
1348
+ std::make_shared<basalt::LogBoxSurfaceDelegate>(
1349
+ [host](const std::string &appKey) {
1350
+ showLogBoxSurface(host, appKey);
1351
+ },
1352
+ [host]() { hideLogBoxSurface(host); }),
1353
+ // `useNativeDriver: true`.
1354
+ // ReactCommon has a C++
1355
+ // implementation of the whole
1356
+ // animated graph and
1357
+ // ReactCxxPlatform provides
1358
+ // the module for it, but only
1359
+ // when a host asks by handing
1360
+ // over a provider. Without it
1361
+ // every native-driven
1362
+ // Animated call throws
1363
+ // "Native animated module is
1364
+ // not available".
1365
+ std::make_shared<
1366
+ facebook::react::
1367
+ NativeAnimatedNodesManagerProvider>(),
1368
+ installBindings,
1369
+ host->choreographer);
1370
+ } catch (const std::exception &error) {
1371
+ g_error("could not construct ReactHost: %s", error.what());
1372
+ }
1373
+
1374
+ // How anything in the core reaches the scheduler, which only the host has.
1375
+ // Reanimated is the one caller; see core/UIManagerAccess.h.
1376
+ basalt::setEventListenerInstaller(
1377
+ [host](std::shared_ptr<const facebook::react::EventListener> listener) {
1378
+ if (host->reactHost == nullptr) {
1379
+ return;
1380
+ }
1381
+ host->reactHost->runOnScheduler(
1382
+ [listener = std::move(listener)](facebook::react::Scheduler &scheduler) {
1383
+ scheduler.addEventListener(listener);
1384
+ });
1385
+ });
1386
+
1387
+ // And how anything in the core reaches the JavaScript thread. An Expo
1388
+ // module is given no call invoker, so this is the only way one can emit an
1389
+ // event or settle a promise later; see core/JsRuntimeAccess.h.
1390
+ basalt::setRuntimeRunner(
1391
+ [host](std::function<void(facebook::jsi::Runtime &)> work) {
1392
+ if (host->reactHost == nullptr) {
1393
+ return;
1394
+ }
1395
+ host->reactHost->runOnRuntimeScheduler(std::move(work));
1396
+ });
1397
+
1398
+ // `loadScript` falls back to the on-disk bundle whenever the Metro fetch
1399
+ // fails, which is right when nothing is listening and wrong when Metro
1400
+ // answered with an error: running the last bundle that built, while the
1401
+ // developer looks at source that is not what is executing, hides the very
1402
+ // thing they need to see. So the fallback is allowed only for the first case,
1403
+ // and the second stops here with Metro's own message.
1404
+ const bool loaded = host->reactHost->loadScript(host->bundlePath, host->sourcePath);
1405
+ if (const auto devError = basalt::devBundleError()) {
1406
+ g_warning("Metro could not build the bundle (HTTP %ld):\n%s",
1407
+ devError->status,
1408
+ devError->message.c_str());
1409
+ // Non-zero, so a script that starts this host can tell a build failure from
1410
+ // a run that ended. The AppKit host returns 1 from main for the same reason.
1411
+ host->exitStatus = 1;
1412
+ g_application_quit(G_APPLICATION(gtk_window_get_application(host->main().window)));
1413
+ return;
1414
+ }
1415
+ if (!loaded) {
1416
+ g_warning("could not load script: %s", host->bundlePath.c_str());
1417
+ gtk_window_present(host->main().window);
1418
+ return;
1419
+ }
1420
+ // Where it came from, not where it might have. See core/DevBundle.h.
1421
+ if (const auto from = basalt::devBundleFetched()) {
1422
+ g_message("loaded script from the packager: %s", from->c_str());
1423
+ } else {
1424
+ g_message("loaded script: %s", host->bundlePath.c_str());
1425
+ }
1426
+
1427
+ // The module name decides who drives the surface.
1428
+ //
1429
+ // Non-empty: SurfaceHandler::start calls AppRegistry.runApplication, React
1430
+ // mounts the registered component, and everything after this point is
1431
+ // ordinary React Native.
1432
+ //
1433
+ // Empty: the surface is registered without JS being called at all, leaving it
1434
+ // for a script to commit into through nativeFabricUIManager by hand. That is
1435
+ // how this host ran before there was a Metro bundle, and js/demo.js still
1436
+ // exercises it.
1437
+ host->reactHost->startSurface(kSurfaceId,
1438
+ host->moduleName,
1439
+ folly::dynamic::object(),
1440
+ constraintsFor(kInitialWidth, kInitialHeight),
1441
+ layoutContextFor(host->main().scaleFactor));
1442
+ host->surfaceStarted = true;
1443
+ g_message("started surface %d%s%s",
1444
+ static_cast<int>(kSurfaceId),
1445
+ host->moduleName.empty() ? " (no module; raw Fabric script)" : " for module ",
1446
+ host->moduleName.c_str());
1447
+
1448
+ gtk_window_present(host->main().window);
1449
+
1450
+ if (host->moduleName.empty()) {
1451
+ g_message("--- committing tree 1 from JS ---");
1452
+ callRenderFunction(host, 1);
1453
+ g_timeout_add(2000, commitSecondTree, host);
1454
+ }
1455
+
1456
+ guint scriptedDelayMs = 1500;
1457
+ if (const char *taps = g_getenv("BASALT_TEST_TAP")) {
1458
+ scriptedDelayMs = scheduleTestTaps(host, taps, scriptedDelayMs, basalt::PointerButton::Primary);
1459
+ }
1460
+ // BASALT_TEST_SECONDARY_TAP: the other button, which presses nothing and
1461
+ // arrives as a pointer event. See core/PointerButtons.h.
1462
+ if (const char *taps = g_getenv("BASALT_TEST_SECONDARY_TAP")) {
1463
+ scriptedDelayMs =
1464
+ scheduleTestTaps(host, taps, scriptedDelayMs, basalt::PointerButton::Secondary);
1465
+ }
1466
+ if (const char *hovers = g_getenv("BASALT_TEST_HOVER")) {
1467
+ scriptedDelayMs = scheduleTestHovers(host, hovers, scriptedDelayMs);
1468
+ }
1469
+ if (const char *scrolls = g_getenv("BASALT_TEST_SCROLL")) {
1470
+ scriptedDelayMs = scheduleTestScrolls(host, scrolls, scriptedDelayMs);
1471
+ }
1472
+ // BASALT_TEST_CLOSE_WINDOW: the surface id of a window to close the way a
1473
+ // person would -- its own close button, not the app asking. The two take
1474
+ // different paths through the host and only one of them can leave a record
1475
+ // whose window is gone, which is why it is worth being able to drive.
1476
+ if (const char *closing = g_getenv("BASALT_TEST_CLOSE_WINDOW")) {
1477
+ const auto surfaceId = static_cast<facebook::react::SurfaceId>(g_ascii_strtoll(closing, nullptr, 10));
1478
+ struct PendingClose {
1479
+ Host *host;
1480
+ facebook::react::SurfaceId surfaceId;
1481
+ };
1482
+ g_timeout_add(
1483
+ scriptedDelayMs,
1484
+ +[](gpointer data) -> gboolean {
1485
+ std::unique_ptr<PendingClose> pending{static_cast<PendingClose *>(data)};
1486
+ g_message("BASALT_TEST_CLOSE_WINDOW: closing window %d",
1487
+ static_cast<int>(pending->surfaceId));
1488
+ HostWindow *target = pending->host->windowFor(pending->surfaceId);
1489
+ if (target != nullptr) {
1490
+ // `close`, not `destroy`: this is the request a close button makes,
1491
+ // so the close-request handler runs exactly as it would for a
1492
+ // person.
1493
+ gtk_window_close(target->window);
1494
+ }
1495
+ return G_SOURCE_REMOVE;
1496
+ },
1497
+ new PendingClose{host, surfaceId});
1498
+ scriptedDelayMs += 1000;
1499
+ }
1500
+
1501
+ // BASALT_TEST_DROP: "x,y:path" -- a file dropped at that point, reported the
1502
+ // way the toolkit would report it.
1503
+ //
1504
+ // Entered below GTK rather than through it, and the reason is the same one
1505
+ // BASALT_TEST_TAP has: a real drag needs a source outside this process, and
1506
+ // there is no way to conjure one from inside a test. What this does exercise
1507
+ // is everything this platform owns -- the hit test, the ancestor walk, the
1508
+ // accept check, the event, and React's half -- which is all of the code
1509
+ // that could be wrong about *which view* is told and *what* it is told.
1510
+ // What it does not exercise is GtkDropTarget itself.
1511
+ if (const char *drop = g_getenv("BASALT_TEST_DROP")) {
1512
+ struct PendingDrop {
1513
+ Host *host;
1514
+ std::string spec;
1515
+ };
1516
+ g_timeout_add(
1517
+ scriptedDelayMs,
1518
+ +[](gpointer data) -> gboolean {
1519
+ std::unique_ptr<PendingDrop> pending{static_cast<PendingDrop *>(data)};
1520
+ const std::string &spec = pending->spec;
1521
+ const size_t comma = spec.find(',');
1522
+ const size_t colon = spec.find(':', comma == std::string::npos ? 0 : comma);
1523
+ if (comma == std::string::npos || colon == std::string::npos) {
1524
+ g_message("BASALT_TEST_DROP: expected x,y:path");
1525
+ return G_SOURCE_REMOVE;
1526
+ }
1527
+ const double x = g_ascii_strtod(spec.substr(0, comma).c_str(), nullptr);
1528
+ const double y = g_ascii_strtod(spec.substr(comma + 1, colon - comma - 1).c_str(), nullptr);
1529
+ const std::string path = spec.substr(colon + 1);
1530
+
1531
+ basalt::DragPayload payload;
1532
+ payload.files.push_back(path);
1533
+
1534
+ const facebook::react::Tag found =
1535
+ basalt::dropTargetAt(pending->host->main().root, x, y, basalt::DropAcceptsFiles);
1536
+ g_message("BASALT_TEST_DROP: %s at %g,%g onto tag %d",
1537
+ path.c_str(), x, y, static_cast<int>(found));
1538
+ if (found == 0) {
1539
+ return G_SOURCE_REMOVE;
1540
+ }
1541
+ basalt::DropEvent event;
1542
+ event.tag = found;
1543
+ event.phase = basalt::DropPhase::Drop;
1544
+ event.x = x;
1545
+ event.y = y;
1546
+ event.payload = payload;
1547
+ basalt::reportDrop(event);
1548
+ return G_SOURCE_REMOVE;
1549
+ },
1550
+ new PendingDrop{host, drop});
1551
+ scriptedDelayMs += 1000;
1552
+ }
1553
+
1554
+ // BASALT_TEST_QUIT: the session ending, which is what quitting means on this
1555
+ // desktop -- there is no Cmd-Q for a GTK app to intercept. Emitting the
1556
+ // signal directly rather than arranging a real logout, for the obvious
1557
+ // reason; what it exercises is everything downstream of the signal, which is
1558
+ // all of this platform's half.
1559
+ //
1560
+ // A count rather than a flag, for the reason the AppKit host gives: one ask
1561
+ // cannot show both halves.
1562
+ if (const char *asks = g_getenv("BASALT_TEST_QUIT")) {
1563
+ const long times = std::max(1L, static_cast<long>(g_ascii_strtoll(asks, nullptr, 10)));
1564
+ for (long ask = 0; ask < times; ask++) {
1565
+ g_timeout_add(
1566
+ scriptedDelayMs,
1567
+ +[](gpointer data) -> gboolean {
1568
+ auto *self = static_cast<Host *>(data);
1569
+ g_message("BASALT_TEST_QUIT: ending the session");
1570
+ if (self != nullptr && self->application != nullptr) {
1571
+ g_signal_emit_by_name(self->application, "query-end");
1572
+ }
1573
+ return G_SOURCE_REMOVE;
1574
+ },
1575
+ host);
1576
+ scriptedDelayMs += 1000;
1577
+ }
1578
+ }
1579
+ // BASALT_TEST_KEY: a declared shortcut, pressed. Enters after the keyval
1580
+ // translation; see core/TestKeys.h for what that does and does not prove.
1581
+ for (const basalt::KeyCombination &pressed : basalt::scriptedKeyPresses()) {
1582
+ g_timeout_add(scriptedDelayMs, fireTestKey, new PendingKey{host, pressed});
1583
+ scriptedDelayMs += 400;
1584
+ }
1585
+
1586
+ if (const char *focus = g_getenv("BASALT_TEST_FOCUS")) {
1587
+ scriptedDelayMs = scheduleTestFocus(host, focus, scriptedDelayMs);
1588
+ }
1589
+ if (const char *drag = g_getenv("BASALT_TEST_DRAG")) {
1590
+ scheduleTestDrag(host, drag, scriptedDelayMs);
1591
+ scriptedDelayMs += 1000;
1592
+ }
1593
+ if (const char *text = g_getenv("BASALT_TEST_TYPE")) {
1594
+ g_timeout_add(scriptedDelayMs, fireTestType, new PendingType{host, text});
1595
+ }
1596
+
1597
+ // What the schedule above came to, so that BASALT_QUIT_WHEN_SETTLED waits for
1598
+ // the last of it rather than only for the first mount. Said once, here, after
1599
+ // everything that moves the total. See core/TestSettle.h.
1600
+ basalt::noteScriptedInputEndsInMs(scriptedDelayMs);
1601
+ g_unix_signal_add(SIGINT, quitOnSignal, app);
1602
+ g_unix_signal_add(SIGTERM, quitOnSignal, app);
1603
+
1604
+ if (const char *quitAfter = g_getenv("BASALT_QUIT_AFTER_MS")) {
1605
+ const gint64 ms = g_ascii_strtoll(quitAfter, nullptr, 10);
1606
+ if (ms > 0) {
1607
+ g_timeout_add(static_cast<guint>(ms), quitAfterTimeout, app);
1608
+ }
1609
+ }
1610
+
1611
+ // BASALT_TEST_QUIT_FILE and BASALT_QUIT_WHEN_SETTLED, polled together.
1612
+ // Registered only when one of them is set, so a path whose file never appears
1613
+ // leaves a harmless timer rather than changing how the app behaves.
1614
+ if (basalt::testQuitFilePath().has_value() || basalt::quitWhenSettledMs().has_value()) {
1615
+ g_timeout_add(basalt::kTestQuitFilePollMs, quitWhenTestFileAppears, app);
1616
+ }
1617
+ }
1618
+
1619
+ // BASALT_DUMP_MENU: write the application menu that is actually installed to a
1620
+ // file on the way out.
1621
+ //
1622
+ // Read back from the platform rather than from the model that was sent, which
1623
+ // is the point: it says a description became a real menu -- and it is the only
1624
+ // way an automated run can see a menu bar at all, since a menu cannot be opened
1625
+ // without a person. Written even when it is empty, because "this platform has
1626
+ // no menu bar" is exactly what a test on Linux is asserting.
1627
+ void dumpMenuIfRequested() {
1628
+ const char *path = g_getenv("BASALT_DUMP_MENU");
1629
+ if (path == nullptr) {
1630
+ return;
1631
+ }
1632
+ const std::string described = basalt::describeApplicationMenu();
1633
+ GError *error = nullptr;
1634
+ if (!g_file_set_contents(path, described.c_str(), static_cast<gssize>(described.size()), &error)) {
1635
+ g_warning("could not write the menu dump: %s", error != nullptr ? error->message : "?");
1636
+ g_clear_error(&error);
1637
+ }
1638
+ }
1639
+
1640
+ // BASALT_DUMP_TREE: write the widget tree to a file on the way out, so an
1641
+ // automated run can assert on what React actually produced rather than on a
1642
+ // screenshot. See docs/TESTING.md.
1643
+ void dumpTreeIfRequested(Host *host) {
1644
+ const char *path = g_getenv("BASALT_DUMP_TREE");
1645
+ if (path == nullptr || host->main().root == nullptr) {
1646
+ return;
1647
+ }
1648
+ GString *out = g_string_new(nullptr);
1649
+ char *appTree = rn_view_describe_tree(host->main().root);
1650
+ g_string_append(out, appTree);
1651
+ g_free(appTree);
1652
+
1653
+ // Every other window, each under a header naming its surface. Appended rather
1654
+ // than merged for the same reason the inspector is: they are separate trees
1655
+ // on screen, and nesting one inside another would say something untrue.
1656
+ for (const auto &other : host->windows) {
1657
+ if (other->surfaceId == kSurfaceId || other->root == nullptr) {
1658
+ continue;
1659
+ }
1660
+ g_string_append_printf(out, "--- window %d ---\n", static_cast<int>(other->surfaceId));
1661
+ char *otherTree = rn_view_describe_tree(other->root);
1662
+ g_string_append(out, otherTree);
1663
+ g_free(otherTree);
1664
+ }
1665
+
1666
+ // The error inspector is a second surface with a root of its own, so it is
1667
+ // invisible to a dump of the app's. Same separator the AppKit host writes.
1668
+ if (host->logBoxRoot != nullptr) {
1669
+ g_string_append(out, "--- LogBox ---\n");
1670
+ char *logBoxTree = rn_view_describe_tree(host->logBoxRoot);
1671
+ g_string_append(out, logBoxTree);
1672
+ g_free(logBoxTree);
1673
+ }
1674
+ char *description = g_string_free(out, FALSE);
1675
+ GError *error = nullptr;
1676
+ if (g_file_set_contents(path, description, -1, &error) == FALSE) {
1677
+ g_warning("could not write %s: %s", path, error != nullptr ? error->message : "unknown error");
1678
+ g_clear_error(&error);
1679
+ } else {
1680
+ g_message("wrote widget tree to %s", path);
1681
+ }
1682
+ g_free(description);
1683
+ }
1684
+
1685
+ void onShutdown(GApplication * /*app*/, gpointer data) {
1686
+ auto *host = static_cast<Host *>(data);
1687
+
1688
+ // Before the surface stops, which tears the tree down.
1689
+ dumpTreeIfRequested(host);
1690
+ dumpMenuIfRequested();
1691
+
1692
+ if (host->choreographer != nullptr) {
1693
+ host->choreographer->detach();
1694
+ }
1695
+ // Stop feeding the beat before the manager it points at is released.
1696
+ basalt::removeRunLoopObserver(host->runLoopObserver);
1697
+ host->runLoopObserver = nullptr;
1698
+ // Every window, not only the app's own -- which is what this said when there
1699
+ // was only ever one, and did not say when there stopped being.
1700
+ //
1701
+ // A dispatcher left alive past `mountingManager.reset()` below is the crash
1702
+ // this file just fixed in the other direction: GTK goes on delivering
1703
+ // crossings and key presses while the widgets come down, and the handler
1704
+ // reads an event emitter out of a manager that has been freed. The
1705
+ // destructors disconnect, so the order here is what decides whether anything
1706
+ // is still listening.
1707
+ for (const auto &window : host->windows) {
1708
+ window->focusManager.reset();
1709
+ window->touchDispatcher.reset();
1710
+ }
1711
+ // Before the host goes: work posted to a host on its way out must not run.
1712
+ basalt::setRuntimeRunner(nullptr);
1713
+ if (host->reactHost != nullptr) {
1714
+ // Surfaces must stop before the host goes away, or teardown asserts.
1715
+ host->reactHost->stopAllSurfaces();
1716
+ host->surfaceStarted = false;
1717
+ // Destroying the host joins the JS thread, so it has to happen while the
1718
+ // mounting manager it holds is still alive.
1719
+ host->reactHost.reset();
1720
+ }
1721
+ host->choreographer.reset();
1722
+ host->runLoopObserverManager.reset();
1723
+ // The window records before the manager that made their roots. Each holds a
1724
+ // borrowed `RnView *` root and a resize callback carrying the record itself,
1725
+ // and a record outliving the manager is the same hazard one line up.
1726
+ host->windows.clear();
1727
+ host->mountingManager.reset();
1728
+ }
1729
+
1730
+ } // namespace
1731
+
1732
+ namespace basalt {
1733
+
1734
+ // core/WindowHost.h, on GTK.
1735
+ //
1736
+ // The surface id is allocated here rather than by JavaScript: it is Fabric's
1737
+ // number, the mounting manager keys its roots by it, and two windows racing to
1738
+ // pick one would be two windows sharing a root. Odd ids only, above the ones
1739
+ // this host reserves -- the app's surface is 1 and the error inspector's is 2.
1740
+ facebook::react::SurfaceId openHostWindow(const NewWindowOptions &options) {
1741
+ Host *host = gWindowHost;
1742
+ if (host == nullptr || host->application == nullptr || !host->surfaceStarted ||
1743
+ options.component.empty()) {
1744
+ return 0;
1745
+ }
1746
+
1747
+ static facebook::react::SurfaceId nextSurfaceId = kLogBoxSurfaceId + 1;
1748
+ const facebook::react::SurfaceId surfaceId = nextSurfaceId++;
1749
+
1750
+ const int width = static_cast<int>(options.width);
1751
+ const int height = static_cast<int>(options.height);
1752
+ HostWindow *made = createHostWindow(host,
1753
+ host->application,
1754
+ surfaceId,
1755
+ options.title.empty() ? "" : options.title.c_str(),
1756
+ width,
1757
+ height);
1758
+
1759
+ // The frame has to exist before the surface starts, for the same reason the
1760
+ // main window's does: Fabric lays the root out against the constraints it is
1761
+ // given here and the widget has not been allocated yet.
1762
+ rn_view_set_frame(made->root, 0, 0, static_cast<float>(width), static_cast<float>(height));
1763
+
1764
+ host->reactHost->startSurface(surfaceId,
1765
+ options.component,
1766
+ options.props,
1767
+ constraintsFor(width, height),
1768
+ layoutContextFor(made->scaleFactor));
1769
+ gtk_window_present(made->window);
1770
+ g_message("opened window %d for module %s",
1771
+ static_cast<int>(surfaceId),
1772
+ options.component.c_str());
1773
+ return surfaceId;
1774
+ }
1775
+
1776
+ // Lets the session manager get on with it.
1777
+ //
1778
+ // Taken in the "query-end" handler and dropped here, so a logout an app
1779
+ // refused does not leave the session wedged if the app later quits by some
1780
+ // other route -- or never answers at all.
1781
+ void releaseQuitInhibit(Host *host) {
1782
+ if (host == nullptr || host->application == nullptr || host->quitInhibitCookie == 0) {
1783
+ return;
1784
+ }
1785
+ gtk_application_uninhibit(host->application, host->quitInhibitCookie);
1786
+ host->quitInhibitCookie = 0;
1787
+ }
1788
+
1789
+ // Ends the application. `g_application_quit` rather than closing every
1790
+ // window: it stops the main loop whatever is open, which is what a person
1791
+ // choosing Quit means, and it runs the "shutdown" handler on the way out.
1792
+ void quitHost() {
1793
+ Host *host = gWindowHost;
1794
+ if (host == nullptr || host->application == nullptr) {
1795
+ return;
1796
+ }
1797
+ // Whatever the session manager was told to wait for, it is over.
1798
+ releaseQuitInhibit(host);
1799
+ g_application_quit(G_APPLICATION(host->application));
1800
+ }
1801
+
1802
+ void closeHostWindow(facebook::react::SurfaceId surfaceId) {
1803
+ Host *host = gWindowHost;
1804
+ // The main window is not closed this way: destroying the surface an app is
1805
+ // running in is not the same thing as closing its window, and an app that
1806
+ // means the second should say so through `close()` on the window itself.
1807
+ if (host == nullptr || surfaceId == kSurfaceId || surfaceId == kLogBoxSurfaceId) {
1808
+ return;
1809
+ }
1810
+
1811
+ if (host->windowFor(surfaceId) == nullptr) {
1812
+ return;
1813
+ }
1814
+
1815
+ // Stopping a surface unmounts its React tree, which produces one last
1816
+ // transaction of Remove and Delete mutations. Those arrive on the UI thread
1817
+ // afterwards, and the widgets they name have to still be there when they do.
1818
+ host->reactHost->stopSurface(surfaceId);
1819
+
1820
+ // So the window is destroyed a round trip later: out to the JavaScript
1821
+ // thread, which is where the teardown runs, and back to this one, which is
1822
+ // where its mutations are applied. Both queues are ordered, so anything the
1823
+ // stop produced is ahead of this.
1824
+ //
1825
+ // Destroying the root immediately instead is survivable -- MountingWalk
1826
+ // treats a mutation naming a tag it does not have as a warning, and says
1827
+ // that a transaction racing a surface teardown is exactly what it is for --
1828
+ // but it logs four of them per window and says something went wrong when
1829
+ // nothing did.
1830
+ host->reactHost->runOnRuntimeScheduler([host, surfaceId](facebook::jsi::Runtime &) {
1831
+ basalt::postToUiThread([host, surfaceId] {
1832
+ HostWindow *going = host->windowFor(surfaceId);
1833
+ if (going == nullptr) {
1834
+ return;
1835
+ }
1836
+ // The dispatchers before the widgets they hold: both keep a borrowed
1837
+ // root and disconnect from it on the way out.
1838
+ going->focusManager.reset();
1839
+ going->touchDispatcher.reset();
1840
+ gtk_window_destroy(going->window);
1841
+ host->mountingManager->destroySurfaceRoot(surfaceId);
1842
+
1843
+ for (auto it = host->windows.begin(); it != host->windows.end(); ++it) {
1844
+ if (it->get() == going) {
1845
+ host->windows.erase(it);
1846
+ break;
1847
+ }
1848
+ }
1849
+ g_message("closed window %d", static_cast<int>(surfaceId));
1850
+ });
1851
+ });
1852
+ }
1853
+
1854
+ std::vector<facebook::react::SurfaceId> hostWindows() {
1855
+ std::vector<facebook::react::SurfaceId> open;
1856
+ if (gWindowHost == nullptr) {
1857
+ return open;
1858
+ }
1859
+ open.reserve(gWindowHost->windows.size());
1860
+ for (const auto &candidate : gWindowHost->windows) {
1861
+ open.push_back(candidate->surfaceId);
1862
+ }
1863
+ return open;
1864
+ }
1865
+
1866
+ } // namespace basalt
1867
+
1868
+ int main(int argc, char **argv) {
1869
+ // glog, before anything logs through it.
1870
+ //
1871
+ // Without this every run opens with "WARNING: Logging before
1872
+ // InitGoogleLogging() is written to STDERR" -- glog saying it has not been
1873
+ // told where to write, in a line that reads like something went wrong on
1874
+ // start-up. It goes to stderr either way; this only stops it apologising for
1875
+ // it. React Native's C++ logs through glog throughout, so the first message
1876
+ // arrives before anything this project writes.
1877
+ FLAGS_logtostderr = true;
1878
+ google::InitGoogleLogging(argv[0]);
1879
+
1880
+ Host host;
1881
+ host.bundlePath = argc > 1 ? argv[1] : "build/main.jsbundle.js";
1882
+ host.moduleName = argc > 2 ? argv[2] : "BasaltDemo";
1883
+
1884
+ // A URL the desktop launched this with, for `Linking.getInitialURL()`. Any
1885
+ // argument after the bundle and the module that carries a scheme: a
1886
+ // `.desktop` entry's `%%u`, a registered scheme, a shell association. Matched
1887
+ // rather than positional, because a launcher appends it and does not know
1888
+ // what came before.
1889
+ for (int i = 1; i < argc; i++) {
1890
+ const std::string argument(argv[i]);
1891
+ if (argument.find("://") != std::string::npos) {
1892
+ basalt::setInitialUrl(argument);
1893
+ break;
1894
+ }
1895
+ }
1896
+ const char *sourcePath = g_getenv("BASALT_DEV_ENTRY");
1897
+ host.sourcePath = sourcePath != nullptr ? sourcePath : "index";
1898
+
1899
+ // The app's Expo config, if this is an Expo app and it has been bundled.
1900
+ // Before anything installs the runtime. See core/ExpoModules.h.
1901
+ basalt::loadExpoAppConfigBeside(host.bundlePath);
1902
+
1903
+ // What this app calls itself, which a generic host binary cannot know. Read
1904
+ // before the GtkApplication, because the application id is construct-only and
1905
+ // is what a desktop matches a running window to its `.desktop` file with --
1906
+ // and therefore what puts the app's own name and icon on its notifications.
1907
+ // See core/AppIdentity.h and cli/packageApp.js.
1908
+ const basalt::AppIdentity &identity = basalt::appIdentity(host.bundlePath);
1909
+
1910
+ // GTK would try to interpret argv as files to open. The bundle path is ours,
1911
+ // so the application never sees it.
1912
+ //
1913
+ // `dev.basalt.host` is the fallback rather than the rule: an app that was
1914
+ // started through `react-native run-linux` has an identity file and gets its
1915
+ // own id. GTK requires a valid D-Bus name, and an identifier that is not one
1916
+ // would make gtk_application_new fail outright, so it is checked first.
1917
+ const char *applicationId =
1918
+ (!identity.identifier.empty() && g_application_id_is_valid(identity.identifier.c_str()))
1919
+ ? identity.identifier.c_str()
1920
+ : "dev.basalt.host";
1921
+ GtkApplication *app = gtk_application_new(applicationId, G_APPLICATION_NON_UNIQUE);
1922
+ g_signal_connect(app, "activate", G_CALLBACK(onActivate), &host);
1923
+ g_signal_connect(app, "shutdown", G_CALLBACK(onShutdown), &host);
1924
+
1925
+ // A monitor plugged in, unplugged or rearranged. The monitor list is a
1926
+ // GListModel, so this is its "items-changed" rather than a signal of its
1927
+ // own -- GTK 4 has no monitors-changed on GdkDisplay.
1928
+ if (GdkDisplay *display = gdk_display_get_default()) {
1929
+ if (GListModel *monitors = gdk_display_get_monitors(display)) {
1930
+ g_signal_connect(monitors,
1931
+ "items-changed",
1932
+ G_CALLBACK(+[](GListModel *, guint, guint, guint, gpointer) {
1933
+ basalt::notifyDisplaysChanged();
1934
+ }),
1935
+ nullptr);
1936
+ }
1937
+ }
1938
+
1939
+ // The session ending -- logout, shutdown, reboot -- which is the only thing
1940
+ // on this desktop that corresponds to macOS's Cmd-Q.
1941
+ //
1942
+ // What GTK does *not* have is an application-level quit gesture to
1943
+ // intercept. There is no Cmd-Q: a GTK app ends when its last window closes,
1944
+ // and that is a close-request on a window, which `useCloseRequest` already
1945
+ // guards. So this handler is the whole of the difference between the two
1946
+ // halves here, where on macOS the difference is most of the feature.
1947
+ //
1948
+ // `gtk_application_inhibit` is what actually holds the session back;
1949
+ // answering the signal is not enough on its own, and an app that only
1950
+ // listened would be told the session is ending and then lose to it. The
1951
+ // cookie is taken when the app asks to intercept and dropped when it stops
1952
+ // -- see quitInterceptionChanged below.
1953
+ g_signal_connect(app,
1954
+ "query-end",
1955
+ G_CALLBACK(+[](GApplication * /*application*/, gpointer data) {
1956
+ if (!basalt::hostQuitIntercepted()) {
1957
+ return;
1958
+ }
1959
+ auto *self = static_cast<Host *>(data);
1960
+ // GTK's own documentation for this signal says to call
1961
+ // inhibit from inside the handler, which is why the
1962
+ // cookie is taken here rather than when the app
1963
+ // registered: answering the signal alone does not hold
1964
+ // the session, and an app that only listened would be
1965
+ // told the session is ending and then lose to it.
1966
+ if (self != nullptr && self->application != nullptr &&
1967
+ self->quitInhibitCookie == 0) {
1968
+ self->quitInhibitCookie =
1969
+ gtk_application_inhibit(self->application,
1970
+ gtk_application_get_active_window(self->application),
1971
+ GTK_APPLICATION_INHIBIT_LOGOUT,
1972
+ "the application has unsaved work");
1973
+ }
1974
+ basalt::hostQuitRequested();
1975
+ }),
1976
+ &host);
1977
+
1978
+ const int status = g_application_run(G_APPLICATION(app), 1, argv);
1979
+ g_object_unref(app);
1980
+ return host.exitStatus != 0 ? host.exitStatus : status;
1981
+ }