basalt-appkit 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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runMacos.js +33 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +298 -0
  5. package/native/appkit/AppKitAnimationChoreographer.h +54 -0
  6. package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
  7. package/native/appkit/AppKitDropTarget.h +34 -0
  8. package/native/appkit/AppKitDropTarget.mm +292 -0
  9. package/native/appkit/AppKitFocus.h +120 -0
  10. package/native/appkit/AppKitFocus.mm +293 -0
  11. package/native/appkit/AppKitImageLoader.h +80 -0
  12. package/native/appkit/AppKitImageLoader.mm +156 -0
  13. package/native/appkit/AppKitKeyEvents.h +26 -0
  14. package/native/appkit/AppKitKeyEvents.mm +97 -0
  15. package/native/appkit/AppKitMenuBar.mm +424 -0
  16. package/native/appkit/AppKitMountingManager.h +135 -0
  17. package/native/appkit/AppKitMountingManager.mm +946 -0
  18. package/native/appkit/AppKitRunLoopObserver.h +34 -0
  19. package/native/appkit/AppKitRunLoopObserver.mm +42 -0
  20. package/native/appkit/AppKitScrollView.h +168 -0
  21. package/native/appkit/AppKitScrollView.mm +550 -0
  22. package/native/appkit/AppKitSkiaContext.h +33 -0
  23. package/native/appkit/AppKitSkiaContext.mm +255 -0
  24. package/native/appkit/AppKitSkiaModule.h +53 -0
  25. package/native/appkit/AppKitSkiaModule.mm +81 -0
  26. package/native/appkit/AppKitSkiaPeer.h +37 -0
  27. package/native/appkit/AppKitSkiaPeer.mm +123 -0
  28. package/native/appkit/AppKitSkiaView.h +62 -0
  29. package/native/appkit/AppKitSnapshot.h +21 -0
  30. package/native/appkit/AppKitSnapshot.mm +91 -0
  31. package/native/appkit/AppKitTextInput.h +140 -0
  32. package/native/appkit/AppKitTextInput.mm +588 -0
  33. package/native/appkit/AppKitTextPeer.h +74 -0
  34. package/native/appkit/AppKitTextPeer.mm +353 -0
  35. package/native/appkit/AppKitTitleBar.h +90 -0
  36. package/native/appkit/AppKitTitleBar.mm +186 -0
  37. package/native/appkit/AppKitTouchDispatcher.h +138 -0
  38. package/native/appkit/AppKitTouchDispatcher.mm +514 -0
  39. package/native/appkit/AppKitWindowControl.mm +222 -0
  40. package/native/appkit/AppKitWindowModule.h +62 -0
  41. package/native/appkit/AppKitWindowModule.mm +194 -0
  42. package/native/appkit/ColorSchemeAppKit.mm +71 -0
  43. package/native/appkit/ComponentRegistryAppKit.mm +121 -0
  44. package/native/appkit/CoreTextLayout.h +43 -0
  45. package/native/appkit/CoreTextLayout.mm +252 -0
  46. package/native/appkit/CoreTextLayoutManager.mm +138 -0
  47. package/native/appkit/FontRegistryCoreText.mm +148 -0
  48. package/native/appkit/PlatformServicesAppKit.mm +540 -0
  49. package/native/appkit/RnAppKitView.h +417 -0
  50. package/native/appkit/RnAppKitView.mm +1605 -0
  51. package/native/appkit/RnTextLayout.h +38 -0
  52. package/native/appkit/RnTextLayout.mm +168 -0
  53. package/native/appkit/demo_layout_appkit.mm +93 -0
  54. package/native/appkit/main_appkit.mm +1860 -0
  55. package/native/appkit/mount_harness_appkit.mm +197 -0
  56. package/native/tests/test_appkit_accessibility.mm +170 -0
  57. package/native/tests/test_appkit_image.mm +237 -0
  58. package/native/tests/test_appkit_input.mm +852 -0
  59. package/native/tests/test_appkit_key_events.mm +150 -0
  60. package/native/tests/test_appkit_mounting.mm +430 -0
  61. package/native/tests/test_appkit_scrollbar.mm +132 -0
  62. package/native/tests/test_appkit_services.mm +57 -0
  63. package/native/tests/test_appkit_skia.mm +212 -0
  64. package/native/tests/test_appkit_text.mm +180 -0
  65. package/native/tests/test_appkit_textinput.mm +748 -0
  66. package/native/tests/test_appkit_titlebar.mm +107 -0
  67. package/native/tests/test_appkit_view.mm +237 -0
  68. package/package.json +26 -0
  69. package/react-native.config.js +22 -0
@@ -0,0 +1,1860 @@
1
+ // basalt-core — the macOS host process.
2
+ //
3
+ // Constructs a ReactHost, loads a script into Hermes, and runs one surface in
4
+ // an NSWindow. Everything on screen from here on is produced by Fabric: the
5
+ // mutation stream arrives through AppKitMountingManager exactly as it does on iOS
6
+ // and Android, and nothing hand-builds a ShadowViewMutation.
7
+ //
8
+ // The counterpart of gtk/main.cpp, and deliberately the same shape. The pieces
9
+ // a host has to supply, and where each comes from:
10
+ //
11
+ // IMountingManager AppKitMountingManager (this repo)
12
+ // Input AppKitTouchDispatcher (this repo)
13
+ // RunLoopObserverManager ReactCxxPlatform (event beat)
14
+ // AnimationChoreographer AppKitAnimationChoreographer (this repo, display link)
15
+ // ContextContainer http + websocket client factories, below
16
+ // ComponentRegistryFactory ComponentRegistryAppKit (via the mounting manager)
17
+ // FontRegistry FontRegistryCoreText (this repo, link-time seam)
18
+ //
19
+ // Threading: AppKit owns this thread. ReactHost spins up its own JS thread and
20
+ // every mount is marshalled back here by AppKitMountingManager. Nothing below
21
+ // touches a view off the main thread.
22
+ //
23
+ // What this host can render is what AppKitMountingManager can mount, which today
24
+ // is <View>. `js/demo.js` -- a surface driven straight through
25
+ // nativeFabricUIManager, with no React and no react-native JavaScript -- is
26
+ // therefore the bundle this runs, and is the same first light-up the GTK host
27
+ // had. A React app needs the JavaScript platform layer to answer to a name
28
+ // other than "linux", which is its own piece of work.
29
+
30
+ #import "AppKitAnimationChoreographer.h"
31
+ #import "AppKitMountingManager.h"
32
+
33
+ #include <react/io/ImageLoaderModule.h>
34
+
35
+ #include "AppIdentity.h"
36
+ #include "AppPaths.h"
37
+ #include "DevMenu.h"
38
+ #include "PointerButtons.h"
39
+ #include "WindowControl.h"
40
+ #include "WindowHost.h"
41
+ #include "DialogModule.h"
42
+ #include "MenuModel.h"
43
+ #include "MenuModule.h"
44
+ #include "WindowsModule.h"
45
+ #include "TestKeys.h"
46
+ #include "TestQuitFile.h"
47
+ #ifdef BASALT_HAS_SKIA
48
+ #include "AppKitSkiaModule.h"
49
+ #endif
50
+ #include "TestSettle.h"
51
+ #import "AppKitRunLoopObserver.h"
52
+ #import "AppKitFocus.h"
53
+ #import "AppKitTouchDispatcher.h"
54
+ #import "AppKitSnapshot.h"
55
+ #import "RnAppKitView.h"
56
+
57
+ #include "AppearanceModule.h"
58
+ #include "BlobModule.h"
59
+ #include "CoreModules.h"
60
+ #include "LogBoxSurface.h"
61
+
62
+ #include <react/renderer/animated/NativeAnimatedNodesManagerProvider.h>
63
+ #include "ColorScheme.h"
64
+ #include "DragAndDrop.h"
65
+
66
+ #import "AppKitDropTarget.h"
67
+ #import "AppKitTitleBar.h"
68
+ #import "AppKitWindowModule.h"
69
+ #include "DevBundle.h"
70
+
71
+ #include <algorithm>
72
+ #include <csignal>
73
+ #include "ExpoModules.h"
74
+ #include "GestureHandlerModule.h"
75
+ #include "ReanimatedModule.h"
76
+ #include "JsRuntimeAccess.h"
77
+ #include "KeyEvents.h"
78
+ #include "PackageModules.h"
79
+ #include "UIManagerAccess.h"
80
+ #include "WorkletsModule.h"
81
+ #include "ExpoRuntime.h"
82
+ #include "TurboModuleProxy.h"
83
+ #include "PlatformConstantsModule.h"
84
+ #include "SourceCodeModule.h"
85
+ #include "StatusBarModule.h"
86
+
87
+ #include <glog/logging.h>
88
+ #include <jsi/jsi.h>
89
+ #include <logger/react_native_log.h>
90
+ #include <react/featureflags/ReactNativeFeatureFlags.h>
91
+ #include <react/featureflags/ReactNativeFeatureFlagsDefaults.h>
92
+ #include <react/http/IHttpClient.h>
93
+ #include <react/http/IWebSocketClient.h>
94
+ #include <react/logging/DefaultOnJsErrorHandler.h>
95
+ #include <react/renderer/core/LayoutConstraints.h>
96
+ #include <react/renderer/core/LayoutContext.h>
97
+ #include <react/runtime/ReactHost.h>
98
+ #include <react/runtime/ReactInstanceConfig.h>
99
+ #include <react/utils/ContextContainer.h>
100
+ #include <react/utils/RunLoopObserverManager.h>
101
+
102
+ #include <cstdlib>
103
+ #include <exception>
104
+ #include <memory>
105
+ #include <string>
106
+
107
+ using facebook::react::ContextContainer;
108
+ using facebook::react::Float;
109
+ using facebook::react::LayoutConstraints;
110
+ using facebook::react::LayoutContext;
111
+ using facebook::react::LayoutDirection;
112
+ using facebook::react::ReactHost;
113
+ using facebook::react::ReactInstanceConfig;
114
+ using facebook::react::RunLoopObserverManager;
115
+ using facebook::react::SurfaceId;
116
+
117
+ namespace {
118
+
119
+ // In Fabric a SurfaceId *is* the root shadow node's tag, which is what lets the
120
+ // surface root live in the mounting manager's registry like any other view.
121
+ constexpr SurfaceId kSurfaceId = 1;
122
+
123
+ // React Native's error inspector, which is a surface of its own -- registered
124
+ // by AppRegistry under the name "LogBox", exactly as an app registers its own
125
+ // component. Started on top of the app's when NativeLogBox.show() asks; see
126
+ // core/LogBoxSurface.h.
127
+ constexpr SurfaceId kLogBoxSurfaceId = 2;
128
+
129
+ constexpr int kInitialWidth = 900;
130
+ constexpr int kInitialHeight = 700;
131
+
132
+ // The entry point for the *scriptless* mode, where the bundle is a hand-written
133
+ // script talking to nativeFabricUIManager directly rather than a React app.
134
+ // See js/demo.js. Only used when no module name is given.
135
+ constexpr const char *kRenderFunctionName = "basaltRender";
136
+
137
+ // One window, and the surface in it.
138
+ //
139
+ // A window is a surface: a surface is what has a size, a layout context and a
140
+ // root shadow node, and two windows sharing one would be two windows sharing a
141
+ // layout. So the id a window is known by *is* its surface id. See
142
+ // core/WindowHost.h.
143
+ //
144
+ // Everything here used to be a field on Host, because there was only ever one.
145
+ // What is still on Host is what is genuinely per-process, and what this project
146
+ // has not made per-window yet: the title bar and the error inspector are the
147
+ // main window's.
148
+ struct HostWindow {
149
+ facebook::react::SurfaceId surfaceId{0};
150
+ NSWindow *window{nil};
151
+ RnAppKitView *root{nil};
152
+ // The window's contentView, holding the app's surface root and -- when the
153
+ // error inspector is showing -- a second root above it.
154
+ //
155
+ // A container rather than making the app root the contentView and adding the
156
+ // inspector inside it: `insertRnChild:atIndex:` indexes into `subviews`, so a
157
+ // view the mutation stream did not put there would shift every later Insert
158
+ // by one.
159
+ NSView *container{nil};
160
+ std::unique_ptr<basalt::AppKitTouchDispatcher> touchDispatcher;
161
+ std::unique_ptr<basalt::AppKitFocusManager> focusManager;
162
+ int scaleFactor{1};
163
+ };
164
+
165
+ struct Host {
166
+ // Every window, main one first. Never empty once the app has started.
167
+ std::vector<std::unique_ptr<HostWindow>> windows;
168
+
169
+ // The window the app was started in. Shorthand for windows.front(), which is
170
+ // what every part of this host that is not yet per-window uses.
171
+ // An empty window with null fields before there is a real one, rather than
172
+ // dereferencing an empty vector. Several things here run before the window
173
+ // exists -- a repaint request, the spinner timer -- and each one already
174
+ // guards on `window == nullptr`, which is exactly what this keeps true.
175
+ HostWindow &main() {
176
+ static HostWindow none;
177
+ return windows.empty() ? none : *windows.front();
178
+ }
179
+
180
+ HostWindow *windowFor(facebook::react::SurfaceId surfaceId) {
181
+ for (const auto &candidate : windows) {
182
+ if (candidate->surfaceId == surfaceId) {
183
+ return candidate.get();
184
+ }
185
+ }
186
+ return nullptr;
187
+ }
188
+
189
+ std::shared_ptr<basalt::AppKitMountingManager> mountingManager;
190
+ std::shared_ptr<RunLoopObserverManager> runLoopObserverManager;
191
+ std::shared_ptr<basalt::AppKitAnimationChoreographer> choreographer;
192
+ // The error inspector's own surface root, or nil when it is not showing.
193
+ RnAppKitView *logBoxRoot{nil};
194
+ std::unique_ptr<ReactHost> reactHost;
195
+
196
+ CFRunLoopObserverRef runLoopObserver{nullptr};
197
+
198
+ std::string bundlePath;
199
+ // Empty means the bundle is a raw Fabric script rather than a React app.
200
+ std::string moduleName;
201
+ // Metro's entry point, without the extension. Only meaningful in dev mode.
202
+ std::string sourcePath;
203
+ bool surfaceStarted{false};
204
+ // Whether this run is a development one, which is the only thing that decides
205
+ // whether Cmd+D opens anything. Kept because the ReactInstanceConfig it came
206
+ // from is not.
207
+ bool devMode{false};
208
+ };
209
+
210
+ Host gHost;
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Logging
214
+ // ---------------------------------------------------------------------------
215
+
216
+ // ReactHost's Logger is where console.log and RN's own logging come out.
217
+ void logToConsole(const std::string &message, unsigned int logLevel) {
218
+ switch (logLevel) {
219
+ case ReactNativeLogLevelFatal:
220
+ case ReactNativeLogLevelError:
221
+ NSLog(@"[js error] %s", message.c_str());
222
+ break;
223
+ case ReactNativeLogLevelWarning:
224
+ NSLog(@"[js warn] %s", message.c_str());
225
+ break;
226
+ default:
227
+ NSLog(@"[js] %s", message.c_str());
228
+ break;
229
+ }
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Layout constraints
234
+ // ---------------------------------------------------------------------------
235
+
236
+ // Minimum == maximum pins the root to the window's content box, which is what
237
+ // a full-window surface wants: the root fills, and flex children divide it.
238
+ LayoutConstraints constraintsFor(int width, int height) {
239
+ // Fully qualified: Carbon's MacTypes.h defines a `Size` of its own -- a bare
240
+ // `long` -- and AppKit pulls it in, so an unqualified name here resolves to
241
+ // something that is not a size at all.
242
+ const facebook::react::Size size{static_cast<Float>(width), static_cast<Float>(height)};
243
+ return LayoutConstraints{
244
+ .minimumSize = size,
245
+ .maximumSize = size,
246
+ .layoutDirection = LayoutDirection::LeftToRight,
247
+ };
248
+ }
249
+
250
+ LayoutContext layoutContextFor(int scaleFactor) {
251
+ return LayoutContext{.pointScaleFactor = static_cast<Float>(scaleFactor)};
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Calling into JS
256
+ // ---------------------------------------------------------------------------
257
+
258
+ // Runs on the JS thread. `step` lets the script commit a second, different tree
259
+ // so an Update/Remove diff can be seen coming out of Fabric rather than only an
260
+ // initial mount.
261
+ void callRenderFunction(int step) {
262
+ if (gHost.reactHost == nullptr) {
263
+ return;
264
+ }
265
+ gHost.reactHost->runOnRuntimeScheduler([step](facebook::jsi::Runtime &runtime) {
266
+ try {
267
+ auto value = runtime.global().getProperty(runtime, kRenderFunctionName);
268
+ if (!value.isObject() || !value.getObject(runtime).isFunction(runtime)) {
269
+ NSLog(@"script defines no global %s()", kRenderFunctionName);
270
+ return;
271
+ }
272
+ value.getObject(runtime).getFunction(runtime).call(
273
+ runtime,
274
+ facebook::jsi::Value(static_cast<int>(kSurfaceId)),
275
+ facebook::jsi::Value(step));
276
+ } catch (const facebook::jsi::JSError &error) {
277
+ NSLog(@"%s() threw: %s\n%s",
278
+ kRenderFunctionName,
279
+ error.getMessage().c_str(),
280
+ error.getStack().c_str());
281
+ } catch (const std::exception &error) {
282
+ NSLog(@"%s() failed: %s", kRenderFunctionName, error.what());
283
+ }
284
+ });
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Startup
289
+ // ---------------------------------------------------------------------------
290
+
291
+ // Feature flags this platform states explicitly. Anything not listed keeps
292
+ // React Native's own default.
293
+ class DesktopFeatureFlags : public facebook::react::ReactNativeFeatureFlagsDefaults {
294
+ public:
295
+ bool enableBridgelessArchitecture() override {
296
+ return true;
297
+ }
298
+ };
299
+
300
+ // TurboModules this platform supplies itself. ReactCxxTurboModuleProvider
301
+ // consults these before its own, so naming a module it also provides replaces
302
+ // it. All three come from core/ and are shared with the GTK host.
303
+ facebook::react::TurboModuleProviders makeTurboModuleProviders(
304
+ std::string scriptURL,
305
+ bool devMode,
306
+ std::weak_ptr<basalt::AppKitImageLoader> imageLoader) {
307
+ facebook::react::TurboModuleProviders providers;
308
+ // Whatever the discovered capability packages supply, first: a package naming
309
+ // a module this host also provides is replacing it deliberately, and the same
310
+ // rule already applies to this list against ReactCxxPlatform's own. Answers
311
+ // nullptr for every name when no package supplies one, which is most builds.
312
+ // See core/PackageModules.h.
313
+ providers.emplace_back(basalt::packageTurboModule);
314
+ providers.emplace_back(
315
+ [scriptURL = std::move(scriptURL), devMode, imageLoader = std::move(imageLoader)](
316
+ const std::string &name,
317
+ const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker)
318
+ -> std::shared_ptr<facebook::react::TurboModule> {
319
+ if (name == facebook::react::PlatformConstantsModule::kModuleName) {
320
+ return std::make_shared<basalt::DesktopPlatformConstantsModule>(jsInvoker);
321
+ }
322
+ // `Image.getSize` and `Image.prefetch`. Upstream builds this module
323
+ // with no loader at all and offers no way to supply one, so the answer
324
+ // is to build it here with the loader this host already has -- which
325
+ // is also the one holding the decoded images, so a size asked for
326
+ // something on screen costs nothing. See docs/backlog/upstream.md.
327
+ if (name == facebook::react::ImageLoaderModule::kModuleName) {
328
+ return std::make_shared<facebook::react::ImageLoaderModule>(jsInvoker, imageLoader);
329
+ }
330
+ if (name == basalt::DesktopAppModule::kModuleName) {
331
+ return std::make_shared<basalt::DesktopAppModule>(jsInvoker);
332
+ }
333
+ if (name == basalt::DesktopAppearanceModule::kModuleName) {
334
+ return std::make_shared<basalt::DesktopAppearanceModule>(jsInvoker);
335
+ }
336
+ if (name == basalt::DesktopBlobModule::kModuleName) {
337
+ return std::make_shared<basalt::DesktopBlobModule>(jsInvoker);
338
+ }
339
+ if (name == basalt::DesktopFileReaderModule::kModuleName) {
340
+ return std::make_shared<basalt::DesktopFileReaderModule>(jsInvoker);
341
+ }
342
+ if (name == basalt::DesktopClipboardModule::kModuleName) {
343
+ return std::make_shared<basalt::DesktopClipboardModule>(jsInvoker);
344
+ }
345
+ if (name == basalt::DesktopVibrationModule::kModuleName) {
346
+ return std::make_shared<basalt::DesktopVibrationModule>(jsInvoker);
347
+ }
348
+ if (name == basalt::DesktopAlertModule::kModuleName) {
349
+ return std::make_shared<basalt::DesktopAlertModule>(jsInvoker);
350
+ }
351
+ if (name == basalt::DesktopLinkingModule::kModuleName) {
352
+ return std::make_shared<basalt::DesktopLinkingModule>(jsInvoker);
353
+ }
354
+ if (name == basalt::DesktopShareModule::kModuleName) {
355
+ return std::make_shared<basalt::DesktopShareModule>(jsInvoker);
356
+ }
357
+ // The native file dialogs, which React Native has no API for and which
358
+ // are the first thing a desktop app reaches for. See core/DialogModule.h.
359
+ if (name == basalt::DesktopDialogModule::kModuleName) {
360
+ return std::make_shared<basalt::DesktopDialogModule>(jsInvoker);
361
+ }
362
+ // The application menu, which on macOS is also what makes Cmd-C reach
363
+ // a text field. See core/MenuModel.h.
364
+ if (name == basalt::DesktopMenuModule::kModuleName) {
365
+ return std::make_shared<basalt::DesktopMenuModule>(jsInvoker);
366
+ }
367
+ // More than one window, which is more than one surface. See
368
+ // core/WindowHost.h.
369
+ if (name == basalt::DesktopWindowsModule::kModuleName) {
370
+ return std::make_shared<basalt::DesktopWindowsModule>(jsInvoker);
371
+ }
372
+ if (name == basalt::DesktopI18nManagerModule::kModuleName) {
373
+ return std::make_shared<basalt::DesktopI18nManagerModule>(jsInvoker);
374
+ }
375
+ if (name == basalt::DesktopAccessibilityManagerModule::kModuleName) {
376
+ return std::make_shared<basalt::DesktopAccessibilityManagerModule>(jsInvoker);
377
+ }
378
+ if (name == basalt::DesktopAccessibilityInfoModule::kModuleName) {
379
+ return std::make_shared<basalt::DesktopAccessibilityInfoModule>(jsInvoker);
380
+ }
381
+ if (name == basalt::DesktopToastModule::kModuleName) {
382
+ return std::make_shared<basalt::DesktopToastModule>(jsInvoker);
383
+ }
384
+ // react-native-gesture-handler's module. Offered whether or not the app
385
+ // has the library: it costs a name comparison, and the alternative is a
386
+ // host that has to be rebuilt to run an app that uses gestures.
387
+ if (name == basalt::DesktopGestureHandlerModule::kModuleName) {
388
+ return std::make_shared<basalt::DesktopGestureHandlerModule>(jsInvoker);
389
+ }
390
+ #ifdef BASALT_HAS_SKIA
391
+ // @shopify/react-native-skia, when the build was pointed at an app that
392
+ // has it. Its `install` is what puts Skia's whole JavaScript API on the
393
+ // global object; see appkit/AppKitSkiaModule.h.
394
+ if (name == basalt::AppKitSkiaModule::kModuleName) {
395
+ return std::make_shared<basalt::AppKitSkiaModule>(jsInvoker);
396
+ }
397
+ #endif
398
+ #ifdef BASALT_HAS_WORKLETS
399
+ // react-native-worklets, when the build was pointed at one. Reanimated
400
+ // is built on it; see core/WorkletsModule.h.
401
+ if (name == basalt::DesktopWorkletsModule::kModuleName) {
402
+ return std::make_shared<basalt::DesktopWorkletsModule>(jsInvoker);
403
+ }
404
+ #endif
405
+ #ifdef BASALT_HAS_REANIMATED
406
+ if (name == basalt::DesktopReanimatedModule::kModuleName) {
407
+ return std::make_shared<basalt::DesktopReanimatedModule>(jsInvoker);
408
+ }
409
+ #endif
410
+ // Only outside dev mode. ReactCxxPlatform provides a real DevSettings
411
+ // when a dev server exists, and this provider is consulted first -- so
412
+ // offering ours unconditionally would shadow the one that works.
413
+ if (!devMode && name == basalt::DesktopDevSettingsModule::kModuleName) {
414
+ return std::make_shared<basalt::DesktopDevSettingsModule>(jsInvoker);
415
+ }
416
+ if (name == basalt::DesktopStatusBarModule::kModuleName) {
417
+ return std::make_shared<basalt::DesktopStatusBarModule>(jsInvoker);
418
+ }
419
+ // The title bar. Reaches the window through the one AppKitTitleBar,
420
+ // which the host attached at startup -- the same arrangement the
421
+ // Windows host has, and for the same reason: a module cannot be handed
422
+ // a window it is created before.
423
+ if (name == basalt::AppKitWindowModule::kModuleName) {
424
+ return std::make_shared<basalt::AppKitWindowModule>(jsInvoker);
425
+ }
426
+ // Only outside dev mode, for the same reason DevSettings is above.
427
+ //
428
+ // This module's scriptURL is what HMRClient registers with Metro as its
429
+ // entry point, and Metro resolves that URL to a *graph*. The URL
430
+ // synthesised here is not the one DevServerHelper actually fetched --
431
+ // that one carries lazy, minify, runModule and app -- so it names a
432
+ // graph nobody built, Metro answers GraphNotFoundError, and Fast
433
+ // Refresh silently never starts. ReactCxxPlatform's own SourceCode
434
+ // module reports the URL the bundle really came from, which is the one
435
+ // whose graph exists.
436
+ if (!devMode && name == basalt::DesktopSourceCodeModule::kModuleName) {
437
+ return std::make_shared<basalt::DesktopSourceCodeModule>(jsInvoker, scriptURL);
438
+ }
439
+ return nullptr;
440
+ });
441
+ return providers;
442
+ }
443
+
444
+ // Whether the window's first responder is one of this project's views, or sits
445
+ // inside one.
446
+ //
447
+ // Inside one matters: a focused `<TextInput>` is an NSTextField the mounting
448
+ // manager put *inside* an RnAppKitView, so asking only whether the responder is
449
+ // an RnAppKitView would answer no for every text field and let a window-level
450
+ // shortcut eat what the field was typing.
451
+ static BOOL basaltViewInResponderChain(NSWindow *window) {
452
+ NSResponder *responder = window.firstResponder;
453
+ if (![responder isKindOfClass:[NSView class]]) {
454
+ return NO;
455
+ }
456
+ for (NSView *view = (NSView *)responder; view != nil; view = view.superview) {
457
+ if ([view isKindOfClass:[RnAppKitView class]]) {
458
+ return YES;
459
+ }
460
+ }
461
+ return NO;
462
+ }
463
+
464
+ // Runs against the JavaScript runtime before the bundle is evaluated, which is
465
+ // the only moment early enough for what goes in here.
466
+ void installBindings(facebook::jsi::Runtime &runtime) {
467
+ // Before Expo's, and before anything else that might look a module up: this is
468
+ // what makes TurboModuleRegistry's first question answerable on a bridgeless
469
+ // runtime. See core/TurboModuleProxy.h.
470
+ basalt::installTurboModuleProxy(runtime);
471
+ basalt::installExpoRuntime(runtime);
472
+ }
473
+
474
+ std::shared_ptr<const ContextContainer> makeContextContainer() {
475
+ auto contextContainer = std::make_shared<ContextContainer>();
476
+
477
+ // ReactHost throws without these two. Both are real implementations --
478
+ // curl-backed http from core/HttpClient.cpp, and React Native's own
479
+ // boost::beast websocket client -- and neither is about a toolkit, which is
480
+ // why the macOS host gets them for free.
481
+ contextContainer->insert(facebook::react::HttpClientFactoryKey,
482
+ facebook::react::getHttpClientFactory());
483
+ contextContainer->insert(facebook::react::WebSocketClientFactoryKey,
484
+ facebook::react::getWebSocketClientFactory());
485
+
486
+ // MessageQueueThreadFactoryKey is deliberately left unset: ReactHost then
487
+ // installs MessageQueueThreadImpl, a real threaded queue.
488
+ return contextContainer;
489
+ }
490
+
491
+ // BASALT_DUMP_TREE: write the view tree to a file on the way out, so an
492
+ // automated run can assert on what React actually produced rather than on a
493
+ // screenshot. The same idea as BASALT_DUMP_TREE, and the prefixes should
494
+ // eventually be one.
495
+ // BASALT_DUMP_MENU: write the application menu that is actually installed to a
496
+ // file on the way out.
497
+ //
498
+ // Read back from AppKit rather than from the model that was sent, which is the
499
+ // point: it says a description became a real NSMenu with the shortcuts the
500
+ // platform attached to its roles -- and it is the only way an automated run can
501
+ // see a menu bar at all, since a menu cannot be opened without a person.
502
+ void dumpMenuIfRequested() {
503
+ const char *path = getenv("BASALT_DUMP_MENU");
504
+ if (path == nullptr) {
505
+ return;
506
+ }
507
+ const std::string described = basalt::describeApplicationMenu();
508
+ NSString *text = [NSString stringWithUTF8String:described.c_str()];
509
+ NSError *error = nil;
510
+ [text != nil ? text : @"" writeToFile:[NSString stringWithUTF8String:path]
511
+ atomically:YES
512
+ encoding:NSUTF8StringEncoding
513
+ error:&error];
514
+ if (error != nil) {
515
+ NSLog(@"could not write the menu dump: %@", error.localizedDescription);
516
+ }
517
+ }
518
+
519
+ void dumpTreeIfRequested() {
520
+ const char *path = getenv("BASALT_DUMP_TREE");
521
+ if (path == nullptr || gHost.main().root == nil) {
522
+ return;
523
+ }
524
+ NSError *error = nil;
525
+ NSMutableString *description = [[gHost.main().root describeTree] mutableCopy];
526
+ // Every other window, each under a header naming its surface. Appended rather
527
+ // than merged for the same reason the inspector is: they are separate trees
528
+ // on screen, and nesting one inside another would say something untrue.
529
+ for (const auto &other : gHost.windows) {
530
+ if (other->surfaceId == kSurfaceId || other->root == nil) {
531
+ continue;
532
+ }
533
+ [description appendFormat:@"--- window %d ---\n", (int)other->surfaceId];
534
+ [description appendString:[other->root describeTree]];
535
+ }
536
+ // The error inspector is a second surface with a root of its own, so it is
537
+ // invisible to a dump of the app's. Appended rather than merged, because the
538
+ // two are siblings on screen and nesting one inside the other would say
539
+ // something untrue about the tree.
540
+ if (gHost.logBoxRoot != nil) {
541
+ [description appendString:@"--- LogBox ---\n"];
542
+ [description appendString:[gHost.logBoxRoot describeTree]];
543
+ }
544
+ if (![description writeToFile:[NSString stringWithUTF8String:path]
545
+ atomically:YES
546
+ encoding:NSUTF8StringEncoding
547
+ error:&error]) {
548
+ NSLog(@"could not write %s: %@", path, error);
549
+ } else {
550
+ NSLog(@"wrote view tree to %s", path);
551
+ }
552
+ }
553
+
554
+ // Starts or stops React Native's error inspector.
555
+ //
556
+ // It is `LogBoxInspectorContainer`, registered by AppRegistry under the name
557
+ // "LogBox" exactly as an app registers its own component -- so this is a second
558
+ // surface rather than an overlay this host draws, and everything in it is React
559
+ // Native's own JavaScript. See core/LogBoxSurface.h.
560
+ //
561
+ // Started and stopped rather than kept and hidden: a surface that exists is a
562
+ // React tree that renders and re-renders, and the inspector is showing for a
563
+ // vanishingly small part of a session.
564
+ void hideLogBoxSurface() {
565
+ if (gHost.reactHost == nullptr || gHost.logBoxRoot == nil) {
566
+ return;
567
+ }
568
+ gHost.reactHost->stopSurface(kLogBoxSurfaceId);
569
+ [gHost.logBoxRoot removeFromSuperview];
570
+ gHost.mountingManager->destroySurfaceRoot(kLogBoxSurfaceId);
571
+ gHost.logBoxRoot = nil;
572
+ }
573
+
574
+ void showLogBoxSurface(const std::string &appKey) {
575
+ if (gHost.reactHost == nullptr || gHost.main().container == nil || gHost.logBoxRoot != nil) {
576
+ return;
577
+ }
578
+
579
+ const NSSize size = gHost.main().container.bounds.size;
580
+ const int width = (int)size.width;
581
+ const int height = (int)size.height;
582
+
583
+ gHost.logBoxRoot = gHost.mountingManager->createSurfaceRoot(kLogBoxSurfaceId);
584
+ [gHost.logBoxRoot setRnFrameX:0 y:0 width:width height:height];
585
+ // Added last, so it is above the app. AppKit paints subviews in order and
586
+ // hit-tests them in reverse, which is what makes the inspector take the
587
+ // presses that would otherwise reach the app behind it.
588
+ [gHost.main().container addSubview:gHost.logBoxRoot];
589
+
590
+ gHost.reactHost->startSurface(kLogBoxSurfaceId,
591
+ appKey,
592
+ folly::dynamic::object(),
593
+ constraintsFor(width, height),
594
+ layoutContextFor(gHost.main().scaleFactor));
595
+ }
596
+
597
+ // Closes any modal sheet before the application tries to go away.
598
+ //
599
+ // Not a testing concern, or not only one: `[NSApp terminate:]` does nothing at
600
+ // all while a sheet is up, so an app showing an `Alert.alert()` when a quit
601
+ // arrives simply does not quit -- no dialog dismissed, no callback, no exit.
602
+ // It looked like a hung automated run, which is how it was found, and it is the
603
+ // same hang a person would get from Cmd-Q.
604
+ //
605
+ // Ending the sheet runs its completion handler, so whatever was waiting on the
606
+ // answer is told the dialog closed rather than left holding a promise forever.
607
+ void endAnyOpenSheets() {
608
+ for (NSWindow *window in NSApp.windows) {
609
+ // A copy, because endSheet: mutates the array being walked.
610
+ for (NSWindow *sheet in [window.sheets copy]) {
611
+ [window endSheet:sheet];
612
+ }
613
+ }
614
+ }
615
+
616
+ void shutdown() {
617
+ // Before the surface stops, which tears the tree down.
618
+ dumpTreeIfRequested();
619
+ dumpMenuIfRequested();
620
+
621
+ if (gHost.choreographer != nullptr) {
622
+ gHost.choreographer->detach();
623
+ }
624
+ // Stop feeding the beat before the manager it points at is released.
625
+ basalt::removeRunLoopObserver(gHost.runLoopObserver);
626
+ gHost.runLoopObserver = nullptr;
627
+
628
+ // Every window, not only the app's own -- which is what this said when there
629
+ // was only ever one, and did not say when there stopped being.
630
+ //
631
+ // A dispatcher left alive past `mountingManager.reset()` below would read an
632
+ // event emitter out of a manager that has been freed, for any event AppKit
633
+ // still delivers while the windows come down. Clearing the back pointer in
634
+ // the dispatcher's own destructor does not cover this: the dispatcher is
635
+ // still there, and it is the manager that has gone.
636
+ for (const auto &window : gHost.windows) {
637
+ window->focusManager.reset();
638
+ window->touchDispatcher.reset();
639
+ }
640
+ gHost.logBoxRoot = nil;
641
+ // Before the host goes: work posted to a host on its way out must not run.
642
+ basalt::setRuntimeRunner(nullptr);
643
+ if (gHost.reactHost != nullptr) {
644
+ // Surfaces must stop before the host goes away, or teardown asserts.
645
+ gHost.reactHost->stopAllSurfaces();
646
+ gHost.surfaceStarted = false;
647
+ // Destroying the host joins the JS thread, so it has to happen while the
648
+ // mounting manager it holds is still alive.
649
+ gHost.reactHost.reset();
650
+ }
651
+ gHost.choreographer.reset();
652
+ gHost.runLoopObserverManager.reset();
653
+ // The window records before the manager that made their roots: each holds a
654
+ // borrowed root, and a record outliving the manager is the same hazard.
655
+ gHost.windows.clear();
656
+ gHost.mountingManager.reset();
657
+ }
658
+
659
+ } // namespace
660
+
661
+ // The window's delegate, and the application's. Resizing has to reach the
662
+ // surface or the root keeps the size it started with while the window changes
663
+ // around it; quitting has to run shutdown() or the JS thread is dropped rather
664
+ // than joined.
665
+ @interface RnAppKitHostDelegate : NSObject <NSWindowDelegate, NSApplicationDelegate>
666
+ @end
667
+
668
+ @implementation RnAppKitHostDelegate
669
+
670
+ // A move changes the bounds `useWindow()` reports and nothing else, so unlike a
671
+ // resize there is no surface to reconstrain -- which is exactly why it needs
672
+ // its own handler: the resize one would never run, and `center()` would look
673
+ // like it had done nothing.
674
+ // Closed by the person rather than by the app: its own close button, or the
675
+ // window manager. Without this the host keeps a record whose NSWindow is gone
676
+ // and the `<Window>` that opened it goes on believing it is open.
677
+ //
678
+ // Only for a window an app opened: the main window closing ends the process,
679
+ // which applicationShouldTerminateAfterLastWindowClosed already arranges.
680
+ // Somebody is trying to close a window: its close button, Cmd-W, or the window
681
+ // manager. AppKit asks before it acts, which is exactly the question an app with
682
+ // unsaved work wants to answer -- so a window that asked to be asked refuses
683
+ // here and tells the app instead.
684
+ //
685
+ // Every window, including the app's own. What this does *not* cover is Cmd-Q:
686
+ // terminating goes through applicationShouldTerminate:, which never asks a
687
+ // window whether it minds -- that is the handler further down.
688
+ - (BOOL)windowShouldClose:(NSWindow *)sender {
689
+ for (const auto &candidate : gHost.windows) {
690
+ if (candidate->window != sender) {
691
+ continue;
692
+ }
693
+ if (basalt::hostWindowCloseIntercepted(candidate->surfaceId)) {
694
+ // What happens next is the app's decision, and if that is "go ahead" it
695
+ // calls close() itself. See core/WindowHost.h for why the answer has to
696
+ // be available before the question is asked.
697
+ basalt::hostWindowCloseRequested(candidate->surfaceId);
698
+ return NO;
699
+ }
700
+ break;
701
+ }
702
+ return YES;
703
+ }
704
+
705
+ - (void)windowWillClose:(NSNotification *)notification {
706
+ for (const auto &candidate : gHost.windows) {
707
+ if (candidate->window != notification.object || candidate->surfaceId == kSurfaceId) {
708
+ continue;
709
+ }
710
+ // Tell JavaScript, then take the window down the same way an app closing it
711
+ // would -- so there is one teardown path rather than two.
712
+ basalt::hostWindowClosed(candidate->surfaceId);
713
+ basalt::closeHostWindow(candidate->surfaceId);
714
+ return;
715
+ }
716
+ }
717
+
718
+ - (void)windowDidMove:(NSNotification *)notification {
719
+ (void)notification;
720
+ basalt::notifyWindowBoundsChanged();
721
+ }
722
+
723
+ - (void)windowDidResize:(NSNotification *)notification {
724
+ if (!gHost.surfaceStarted) {
725
+ return;
726
+ }
727
+ // Which window resized, which is the whole of what more than one changes
728
+ // here. Constraining `kSurfaceId` from every window would lay the *first*
729
+ // window's tree out to the second window's size, and the symptom would be a
730
+ // first window whose content jumped whenever a second one was dragged.
731
+ HostWindow *resized = nullptr;
732
+ for (const auto &candidate : gHost.windows) {
733
+ if (candidate->window == notification.object) {
734
+ resized = candidate.get();
735
+ break;
736
+ }
737
+ }
738
+ if (resized == nullptr) {
739
+ return;
740
+ }
741
+
742
+ const NSSize size = resized->window.contentView.bounds.size;
743
+ const int width = (int)size.width;
744
+ const int height = (int)size.height;
745
+ if (width <= 0 || height <= 0) {
746
+ return;
747
+ }
748
+ [resized->root setRnFrameX:0 y:0 width:width height:height];
749
+ gHost.reactHost->setSurfaceConstraints(
750
+ resized->surfaceId, constraintsFor(width, height), layoutContextFor(resized->scaleFactor));
751
+
752
+ // A <Modal> is sized from its own shadow-node state rather than from a style,
753
+ // and React Native's C++ platform answers "what size is the screen" with
754
+ // zero. Told here, where the window's size is already being handed to Fabric.
755
+ gHost.mountingManager->setSurfaceSize((float)width, (float)height);
756
+ // The window moved or was resized, which `useWindow()` hands an app as live
757
+ // bounds. Here rather than in a watcher of its own: this is already the one
758
+ // place that learns it.
759
+ basalt::notifyWindowBoundsChanged();
760
+
761
+ // The inspector covers the main window, so it resizes with it -- and only
762
+ // with it: an error is about the app rather than about a window.
763
+ if (gHost.logBoxRoot != nil && resized->surfaceId == kSurfaceId) {
764
+ [gHost.logBoxRoot setRnFrameX:0 y:0 width:width height:height];
765
+ gHost.reactHost->setSurfaceConstraints(
766
+ kLogBoxSurfaceId, constraintsFor(width, height), layoutContextFor(gHost.main().scaleFactor));
767
+ }
768
+ }
769
+
770
+ // The backing scale factor changes when a window moves between a Retina display
771
+ // and an external one. Yoga rounds to physical pixels, so telling it the wrong
772
+ // factor puts half-pixel seams between adjacent views.
773
+ - (void)windowDidChangeBackingProperties:(NSNotification *)notification {
774
+ (void)notification;
775
+ if (!gHost.surfaceStarted) {
776
+ return;
777
+ }
778
+ gHost.main().scaleFactor = (int)gHost.main().window.backingScaleFactor;
779
+ const NSSize size = gHost.main().window.contentView.bounds.size;
780
+ gHost.reactHost->setSurfaceConstraints(kSurfaceId,
781
+ constraintsFor((int)size.width, (int)size.height),
782
+ layoutContextFor(gHost.main().scaleFactor));
783
+ }
784
+
785
+ // Set by the BASALT_QUIT_AFTER_MS timer, read by applicationShouldTerminate:.
786
+ // A file-scope bool rather than anything in core: it is about this host's
787
+ // automation and means nothing to the platform.
788
+ static bool gQuittingForTest = false;
789
+
790
+ - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender {
791
+ (void)sender;
792
+ return YES;
793
+ }
794
+
795
+ // Somebody is trying to quit: Cmd-Q, the Quit menu item, or a logout. AppKit
796
+ // asks the delegate and asks no window, which is why guarding every window is
797
+ // not enough -- see core/WindowHost.h.
798
+ //
799
+ // `NSTerminateCancel` rather than `NSTerminateLater`. Later is the shape this
800
+ // looks like it wants, and it obliges the app to call
801
+ // `replyToApplicationShouldTerminate:` -- from the JavaScript thread, having
802
+ // woken React, decided, and hopped back -- with AppKit holding a modal run
803
+ // loop in the meantime. An app that is slow to answer, or that never answers
804
+ // because its handler threw, leaves a process that cannot be quit by any
805
+ // means short of kill. Cancel is a complete answer now, and the app quits
806
+ // itself when it is ready, which is exactly the arrangement the window half
807
+ // already uses.
808
+ // A monitor plugged in, unplugged, or rearranged -- and also a resolution
809
+ // change or the menu bar moving to another screen, which is why the name says
810
+ // "screen parameters" rather than "screens".
811
+ - (void)applicationDidChangeScreenParameters:(NSNotification *)notification {
812
+ (void)notification;
813
+ basalt::notifyDisplaysChanged();
814
+ }
815
+
816
+ - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender {
817
+ (void)sender;
818
+ // The automation timer is not refusable. BASALT_QUIT_AFTER_MS quits by
819
+ // calling terminate:, which arrives here -- so a demo that registers a quit
820
+ // handler would refuse the harness, and every scenario that runs it would
821
+ // hang until its own timeout and report something other than what it is
822
+ // testing. The scenario for this feature is exactly such a demo.
823
+ if (gQuittingForTest) {
824
+ return NSTerminateNow;
825
+ }
826
+ if (!basalt::hostQuitIntercepted()) {
827
+ return NSTerminateNow;
828
+ }
829
+ basalt::hostQuitRequested();
830
+ return NSTerminateCancel;
831
+ }
832
+
833
+ - (void)applicationWillTerminate:(NSNotification *)notification {
834
+ (void)notification;
835
+ shutdown();
836
+ }
837
+
838
+ @end
839
+
840
+ // The application menu an app gets before it has asked for one.
841
+ //
842
+ // Not a courtesy, and not only about Quit. AppKit gives the main menu every key
843
+ // event before the responder chain sees it, so a host with no Edit menu has no
844
+ // working Cmd-C: `copy:` never reaches the field editor of whichever text field
845
+ // has focus. This host had exactly one Quit item until core/MenuModel.h
846
+ // existed, and copy, cut, paste, undo and select-all did nothing in every
847
+ // <TextInput> on macOS as a result.
848
+ //
849
+ // An empty model is the default; see appkit/AppKitMenuBar.mm for what it
850
+ // contains and why each item is in it.
851
+ static void installMainMenu(void) {
852
+ basalt::setApplicationMenu(basalt::MenuModel{}, nullptr);
853
+ }
854
+
855
+ // Makes a window, a surface root for it, and the input that drives them.
856
+ //
857
+ // Called for the main window and for every one an app opens afterwards, which
858
+ // is the point: the second window is not a special case of the first, it is the
859
+ // same function with a different surface id. See core/WindowHost.h.
860
+ //
861
+ // The window is not shown and the surface is not started here; both are the
862
+ // caller's business.
863
+ HostWindow *createHostWindow(facebook::react::SurfaceId surfaceId,
864
+ NSString *title,
865
+ int width,
866
+ int height,
867
+ id<NSWindowDelegate> delegate) {
868
+ // The first window through here is the app's own, and the title bar belongs
869
+ // to it alone: it is a process-wide seam -- one title, one style -- and
870
+ // making it per-window is its own piece of work. See docs/BACKLOG.md.
871
+ const bool isMainWindow = gHost.windows.empty();
872
+
873
+ auto owned = std::make_unique<HostWindow>();
874
+ HostWindow *made = owned.get();
875
+ made->surfaceId = surfaceId;
876
+
877
+ made->window = [[NSWindow alloc]
878
+ initWithContentRect:NSMakeRect(0, 0, width, height)
879
+ styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
880
+ NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable
881
+ backing:NSBackingStoreBuffered
882
+ defer:NO];
883
+ made->window.title = title;
884
+ made->window.delegate = delegate;
885
+ // Released when closed would free the window out from under this record. The
886
+ // host owns every window it makes and closes them through closeHostWindow.
887
+ made->window.releasedWhenClosed = NO;
888
+ if (isMainWindow) {
889
+ // The title bar talks to this window from here on; see AppKitTitleBar.h.
890
+ basalt::titleBar().attach(made->window);
891
+ }
892
+ [made->window center];
893
+ made->scaleFactor = (int)made->window.backingScaleFactor;
894
+
895
+ // Fabric emits no Create for a surface root -- the root shadow node is the
896
+ // base of every diff, so it has to exist before the surface starts.
897
+ made->root = gHost.mountingManager->createSurfaceRoot(surfaceId);
898
+ [made->root setRnFrameX:0 y:0 width:width height:height];
899
+ made->container = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, width, height)];
900
+ made->container.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
901
+ [made->container addSubview:made->root];
902
+ made->window.contentView = made->container;
903
+
904
+ // Input, per window. Attached to this window's root, which is where its hit
905
+ // testing starts -- a second window with the first one's dispatcher would
906
+ // deliver every press to the wrong tree.
907
+ made->touchDispatcher =
908
+ std::make_unique<basalt::AppKitTouchDispatcher>(gHost.mountingManager.get(), made->root);
909
+ // The keyboard half: Tab reaching a <Pressable>, and Enter activating it.
910
+ made->focusManager =
911
+ std::make_unique<basalt::AppKitFocusManager>(gHost.mountingManager.get(), made->root);
912
+
913
+ gHost.windows.push_back(std::move(owned));
914
+ return made;
915
+ }
916
+
917
+ namespace basalt {
918
+
919
+ // core/WindowHost.h, on macOS.
920
+ //
921
+ // The surface id is allocated here rather than by JavaScript: it is Fabric's
922
+ // number, the mounting manager keys its roots by it, and two windows racing to
923
+ // pick one would be two windows sharing a root. Above the ids this host
924
+ // reserves -- the app's surface is 1 and the error inspector's is 2.
925
+ facebook::react::SurfaceId openHostWindow(const NewWindowOptions &options) {
926
+ if (gHost.reactHost == nullptr || !gHost.surfaceStarted || options.component.empty() ||
927
+ gHost.windows.empty()) {
928
+ return 0;
929
+ }
930
+
931
+ @autoreleasepool {
932
+ static facebook::react::SurfaceId nextSurfaceId = kLogBoxSurfaceId + 1;
933
+ const facebook::react::SurfaceId surfaceId = nextSurfaceId++;
934
+
935
+ const int width = (int)options.width;
936
+ const int height = (int)options.height;
937
+ NSString *title = [NSString stringWithUTF8String:options.title.c_str()];
938
+ // The same delegate the main window has: it answers windowDidResize: and
939
+ // windowDidMove:, both of which every window needs.
940
+ HostWindow *made = createHostWindow(surfaceId,
941
+ title != nil ? title : @"",
942
+ width,
943
+ height,
944
+ (id<NSWindowDelegate>)NSApp.delegate);
945
+
946
+ gHost.reactHost->startSurface(surfaceId,
947
+ options.component,
948
+ options.props,
949
+ constraintsFor(width, height),
950
+ layoutContextFor(made->scaleFactor));
951
+ [made->window makeKeyAndOrderFront:nil];
952
+ NSLog(@"opened window %d for module %s", (int)surfaceId, options.component.c_str());
953
+ return surfaceId;
954
+ }
955
+ }
956
+
957
+ // Ends the application.
958
+ //
959
+ // `terminate:` rather than `exit()`: it runs
960
+ // `applicationWillTerminate:`, which is where shutdown() is, and it is what
961
+ // the Quit menu item would have done had the app not refused it. The refusal
962
+ // is the app's own flag, and an app calling this has already cleared it --
963
+ // see the hook in src/quitRequest.ts -- so this is not refused a second time.
964
+ void quitHost() {
965
+ [NSApp terminate:nil];
966
+ }
967
+
968
+ void closeHostWindow(facebook::react::SurfaceId surfaceId) {
969
+ // The main window is not closed this way: destroying the surface an app is
970
+ // running in is not the same thing as closing its window, and an app that
971
+ // means the second should say so through `close()` on the window itself.
972
+ if (gHost.reactHost == nullptr || surfaceId == kSurfaceId || surfaceId == kLogBoxSurfaceId) {
973
+ return;
974
+ }
975
+ if (gHost.windowFor(surfaceId) == nullptr) {
976
+ return;
977
+ }
978
+
979
+ // Stopping a surface unmounts its React tree, which produces one last
980
+ // transaction of Remove and Delete mutations. Those arrive on the main thread
981
+ // afterwards, and the views they name have to still be there when they do.
982
+ gHost.reactHost->stopSurface(surfaceId);
983
+
984
+ // So the window goes a round trip later: out to the JavaScript thread, which
985
+ // is where the teardown runs, and back to this one, which is where its
986
+ // mutations are applied. Both queues are ordered, so anything the stop
987
+ // produced is ahead of this.
988
+ gHost.reactHost->runOnRuntimeScheduler([surfaceId](facebook::jsi::Runtime &) {
989
+ basalt::postToUiThread([surfaceId] {
990
+ HostWindow *going = gHost.windowFor(surfaceId);
991
+ if (going == nullptr) {
992
+ return;
993
+ }
994
+ // The dispatchers before the views they hold: both keep a borrowed root.
995
+ going->focusManager.reset();
996
+ going->touchDispatcher.reset();
997
+ going->window.delegate = nil;
998
+ [going->window close];
999
+ gHost.mountingManager->destroySurfaceRoot(surfaceId);
1000
+
1001
+ for (auto it = gHost.windows.begin(); it != gHost.windows.end(); ++it) {
1002
+ if (it->get() == going) {
1003
+ gHost.windows.erase(it);
1004
+ break;
1005
+ }
1006
+ }
1007
+ NSLog(@"closed window %d", (int)surfaceId);
1008
+ });
1009
+ });
1010
+ }
1011
+
1012
+ std::vector<facebook::react::SurfaceId> hostWindows() {
1013
+ std::vector<facebook::react::SurfaceId> open;
1014
+ open.reserve(gHost.windows.size());
1015
+ for (const auto &candidate : gHost.windows) {
1016
+ open.push_back(candidate->surfaceId);
1017
+ }
1018
+ return open;
1019
+ }
1020
+
1021
+ } // namespace basalt
1022
+
1023
+ int main(int argc, const char *argv[]) {
1024
+ // glog, before anything logs through it.
1025
+ //
1026
+ // Without this every run opens with "WARNING: Logging before
1027
+ // InitGoogleLogging() is written to STDERR" -- glog saying it has not been
1028
+ // told where to write, in a line that reads like something went wrong on
1029
+ // start-up. It goes to stderr either way; this only stops it apologising for
1030
+ // it. React Native's C++ logs through glog throughout, so the first message
1031
+ // arrives before anything this project writes.
1032
+ FLAGS_logtostderr = true;
1033
+ google::InitGoogleLogging(argv[0]);
1034
+
1035
+ @autoreleasepool {
1036
+ gHost.bundlePath = argc > 1 ? argv[1] : "build/main.jsbundle.js";
1037
+
1038
+ // A URL the desktop launched this with, for `Linking.getInitialURL()`. Any
1039
+ // argument after the bundle and the module that carries a scheme: a
1040
+ // `.desktop` entry's `%%u`, a registered scheme, a shell association. Matched
1041
+ // rather than positional, because a launcher appends it and does not know
1042
+ // what came before.
1043
+ for (int i = 1; i < argc; i++) {
1044
+ const std::string argument(argv[i]);
1045
+ if (argument.find("://") != std::string::npos) {
1046
+ basalt::setInitialUrl(argument);
1047
+ break;
1048
+ }
1049
+ }
1050
+ // Defaults to the raw-Fabric script, because that is what this host can
1051
+ // currently render: a React app needs components macOS cannot mount yet.
1052
+ gHost.moduleName = argc > 2 ? argv[2] : "";
1053
+ const char *sourcePath = getenv("BASALT_DEV_ENTRY");
1054
+ gHost.sourcePath = sourcePath != nullptr ? sourcePath : "index";
1055
+
1056
+ // The app's Expo config, if this is an Expo app and it has been bundled.
1057
+ // Before anything installs the runtime. See core/ExpoModules.h.
1058
+ basalt::loadExpoAppConfigBeside(gHost.bundlePath);
1059
+
1060
+ [NSApplication sharedApplication];
1061
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
1062
+ installMainMenu();
1063
+
1064
+ RnAppKitHostDelegate *delegate = [[RnAppKitHostDelegate alloc] init];
1065
+ NSApp.delegate = delegate;
1066
+
1067
+ // Constructed here, on the main thread: AppKitMountingManager records this
1068
+ // thread and asserts that every mutation lands back on it.
1069
+ gHost.mountingManager = std::make_shared<basalt::AppKitMountingManager>();
1070
+
1071
+ // The application's own window, which is windows.front() from here on and
1072
+ // is what everything not yet per-window means by "the window".
1073
+ createHostWindow(kSurfaceId,
1074
+ @"basalt-core — macOS",
1075
+ kInitialWidth,
1076
+ kInitialHeight,
1077
+ delegate);
1078
+
1079
+
1080
+ gHost.runLoopObserverManager = std::make_shared<RunLoopObserverManager>();
1081
+ gHost.choreographer = std::make_shared<basalt::AppKitAnimationChoreographer>();
1082
+
1083
+ // Light or dark, and any change to it. Before ReactHost, so the module can
1084
+ // answer the first `Appearance.getColorScheme()` a bundle makes, which for an
1085
+ // Expo app is during its first import.
1086
+ basalt::startObservingColorScheme();
1087
+
1088
+ // Before ReactHost, so the beat is being induced from the first event on.
1089
+ gHost.runLoopObserver = basalt::installRunLoopObserver(gHost.runLoopObserverManager);
1090
+
1091
+ // Say what this host needs rather than inheriting a default that moves.
1092
+ // HermesInstance gives the runtime a microtask queue only when
1093
+ // enableBridgelessArchitecture() is true, and React's scheduler enqueues a
1094
+ // microtask on its first render. This host is bridgeless -- there is no
1095
+ // bridge here to be the alternative -- so it asserts that.
1096
+ facebook::react::ReactNativeFeatureFlags::override(
1097
+ std::make_unique<DesktopFeatureFlags>());
1098
+
1099
+ ReactInstanceConfig config;
1100
+ // The appId is how this host tells Metro which platform it is; see the
1101
+ // matching comment in gtk/main.cpp and APP_ID_PREFIX in
1102
+ // packages/basalt-core/metro-config.js.
1103
+ config.appId = "basalt-macos";
1104
+ config.deviceName = "macos";
1105
+
1106
+ // Dev mode changes three things at once: loadScript tries Metro before the
1107
+ // on-disk bundle; DevServerHelper exists, which is the only condition under
1108
+ // which ReactCxxTurboModuleProvider serves the DevSettings module a __DEV__
1109
+ // bundle requires; and ReactHost opens a packager connection whose reload
1110
+ // message reloads the instance.
1111
+ // What this app calls itself. Nothing on macOS reads it yet -- the bundle's
1112
+ // Info.plist is what the system goes by there, and cli/packageApp.js writes
1113
+ // that -- but reading it here keeps `basalt::appIdentity()` answering on all
1114
+ // three hosts rather than on one. See core/AppIdentity.h.
1115
+ basalt::appIdentity(gHost.bundlePath);
1116
+
1117
+ config.enableDevMode = getenv("BASALT_DEV") != nullptr;
1118
+ gHost.devMode = config.enableDevMode;
1119
+ config.enableInspector = config.enableDevMode;
1120
+ if (const char *devHost = getenv("BASALT_DEV_HOST")) {
1121
+ config.devServerHost = devHost;
1122
+ }
1123
+ if (const char *devPort = getenv("BASALT_DEV_PORT")) {
1124
+ config.devServerPort = (uint32_t)strtoul(devPort, nullptr, 10);
1125
+ }
1126
+ // So the http client can tell the bundle request apart from an app's own
1127
+ // fetch, and refuse to feed a Metro error page to the JS engine. See
1128
+ // core/DevBundle.h.
1129
+ if (config.enableDevMode) {
1130
+ basalt::setDevServerOrigin(config.devServerHost, config.devServerPort);
1131
+ }
1132
+
1133
+ try {
1134
+ gHost.reactHost = std::make_unique<ReactHost>(
1135
+ config,
1136
+ gHost.mountingManager,
1137
+ gHost.runLoopObserverManager,
1138
+ makeContextContainer(),
1139
+ facebook::react::getDefaultOnJsErrorFunc(),
1140
+ logToConsole,
1141
+ nullptr,
1142
+ makeTurboModuleProviders(basalt::scriptURLFor(
1143
+ gHost.bundlePath,
1144
+ config.enableDevMode,
1145
+ config.devServerHost,
1146
+ config.devServerPort,
1147
+ gHost.sourcePath.empty() ? "index" : gHost.sourcePath,
1148
+ "macos"),
1149
+ config.enableDevMode,
1150
+ gHost.mountingManager->imageLoader()),
1151
+ // React Native's error inspector. ReactCxxPlatform implements the
1152
+ // LogBox TurboModule itself and only provides it when a host hands
1153
+ // over one of these; passing null, as this did, left
1154
+ // NativeLogBox.show() a call into nothing. See core/LogBoxSurface.h.
1155
+ std::make_shared<basalt::LogBoxSurfaceDelegate>(
1156
+ [](const std::string &appKey) { showLogBoxSurface(appKey); },
1157
+ []() { hideLogBoxSurface(); }),
1158
+ // `useNativeDriver: true`. ReactCommon has a C++ implementation of
1159
+ // the whole animated graph, and ReactCxxPlatform provides the module
1160
+ // for it -- but only when a host asks, by handing over a provider.
1161
+ // Without it every native-driven Animated call throws "Native
1162
+ // animated module is not available", which is also what kept
1163
+ // LogBox's own spinner from rendering.
1164
+ std::make_shared<facebook::react::NativeAnimatedNodesManagerProvider>(),
1165
+ installBindings,
1166
+ gHost.choreographer);
1167
+ } catch (const std::exception &error) {
1168
+ NSLog(@"could not construct ReactHost: %s", error.what());
1169
+ return 1;
1170
+ }
1171
+
1172
+ // How anything in the core reaches the scheduler, which only the host has.
1173
+ // Reanimated is the one caller; see core/UIManagerAccess.h.
1174
+ basalt::setEventListenerInstaller(
1175
+ [](std::shared_ptr<const facebook::react::EventListener> listener) {
1176
+ if (gHost.reactHost == nullptr) {
1177
+ return;
1178
+ }
1179
+ gHost.reactHost->runOnScheduler([listener = std::move(listener)](
1180
+ facebook::react::Scheduler &scheduler) {
1181
+ scheduler.addEventListener(listener);
1182
+ });
1183
+ });
1184
+
1185
+ // And how anything in the core reaches the JavaScript thread. An Expo
1186
+ // module is given no call invoker, so this is the only way one can emit an
1187
+ // event or settle a promise later; see core/JsRuntimeAccess.h.
1188
+ basalt::setRuntimeRunner(
1189
+ [](std::function<void(facebook::jsi::Runtime &)> work) {
1190
+ if (gHost.reactHost == nullptr) {
1191
+ return;
1192
+ }
1193
+ gHost.reactHost->runOnRuntimeScheduler(std::move(work));
1194
+ });
1195
+
1196
+ // `loadScript` falls back to the on-disk bundle whenever the Metro fetch
1197
+ // fails, which is right when nothing is listening and wrong when Metro
1198
+ // answered with an error: running the last bundle that built, while the
1199
+ // developer looks at source that is not what is executing, hides the very
1200
+ // thing they need to see. So the fallback is allowed only for the first
1201
+ // case, and the second stops here with Metro's own message.
1202
+ const bool loaded = gHost.reactHost->loadScript(gHost.bundlePath, gHost.sourcePath);
1203
+ if (const auto devError = basalt::devBundleError()) {
1204
+ NSLog(@"Metro could not build the bundle (HTTP %ld):\n%s",
1205
+ devError->status,
1206
+ devError->message.c_str());
1207
+ // Through shutdown() rather than straight out: the fallback bundle is
1208
+ // already running on the JS thread, and dropping the host without
1209
+ // joining it aborts in a destructor instead of exiting.
1210
+ shutdown();
1211
+ return 1;
1212
+ }
1213
+ if (!loaded) {
1214
+ NSLog(@"could not load script: %s", gHost.bundlePath.c_str());
1215
+ shutdown();
1216
+ return 1;
1217
+ }
1218
+ // Where it came from, not where it might have. See core/DevBundle.h.
1219
+ if (const auto from = basalt::devBundleFetched()) {
1220
+ NSLog(@"loaded script from the packager: %s", from->c_str());
1221
+ } else {
1222
+ NSLog(@"loaded script: %s", gHost.bundlePath.c_str());
1223
+ }
1224
+
1225
+ // The module name decides who drives the surface. Non-empty:
1226
+ // SurfaceHandler::start calls AppRegistry.runApplication. Empty: the
1227
+ // surface is registered without JS being called at all, leaving it for a
1228
+ // script to commit into through nativeFabricUIManager by hand.
1229
+ gHost.reactHost->startSurface(kSurfaceId,
1230
+ gHost.moduleName,
1231
+ folly::dynamic::object(),
1232
+ constraintsFor(kInitialWidth, kInitialHeight),
1233
+ layoutContextFor(gHost.main().scaleFactor));
1234
+ gHost.mountingManager->setSurfaceSize((float)kInitialWidth, (float)kInitialHeight);
1235
+ gHost.surfaceStarted = true;
1236
+ NSLog(@"started surface %d%s%s",
1237
+ (int)kSurfaceId,
1238
+ gHost.moduleName.empty() ? " (no module; raw Fabric script)" : " for module ",
1239
+ gHost.moduleName.c_str());
1240
+
1241
+ [gHost.main().window makeKeyAndOrderFront:nil];
1242
+ [NSApp activateIgnoringOtherApps:YES];
1243
+ // Prime the bounds cache, which `getBounds()` answers from: without this an
1244
+ // app's first render sees a window of no size, and only a later resize
1245
+ // corrects it. See core/WindowBoundsCache.cpp.
1246
+ basalt::notifyWindowBoundsChanged();
1247
+
1248
+ // Accepting what the desktop drags onto this window. On the surface root
1249
+ // rather than per view: AppKit asks the destination under the pointer,
1250
+ // and which *app* view that is is core/DragAndDrop.h's question.
1251
+ basalt::attachDropTarget(gHost.main().root);
1252
+
1253
+ // And the display list, which `getDisplays()` answers from. Same reason,
1254
+ // and the same trap: the first version of this primed from
1255
+ // windowDidResize, which is not startup -- an app that never resized its
1256
+ // window was told the desktop had no screens.
1257
+ basalt::notifyDisplaysChanged();
1258
+
1259
+ // The two keys this host answers before anything else sees them.
1260
+ //
1261
+ // Escape closes the topmost <Modal> -- or rather, asks the app to. React
1262
+ // Native's `onRequestClose` is the hardware back button on Android and the
1263
+ // swipe-down on iOS; on a desktop it is Escape.
1264
+ //
1265
+ // Cmd+D opens React Native's developer menu, which is the shortcut it uses
1266
+ // on a simulator and the closest thing a desktop has to a phone's shake.
1267
+ //
1268
+ // A local event monitor rather than `keyDown:` on a view, because neither
1269
+ // is about the focused view and nothing in the window need have focus. The
1270
+ // monitor swallows a key only when it actually did something, so both
1271
+ // still do whatever they did before everywhere else.
1272
+ [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown
1273
+ handler:^NSEvent *(NSEvent *event) {
1274
+ const unichar first =
1275
+ event.charactersIgnoringModifiers.length > 0
1276
+ ? [event.charactersIgnoringModifiers
1277
+ characterAtIndex:0]
1278
+ : 0;
1279
+ const BOOL command =
1280
+ (event.modifierFlags &
1281
+ NSEventModifierFlagCommand) != 0;
1282
+ if (command && (first == 'd' || first == 'D') &&
1283
+ gHost.devMode && gHost.reactHost != nullptr) {
1284
+ basalt::showDevMenu(gHost.reactHost.get());
1285
+ return nil;
1286
+ }
1287
+ if (first == 0x1B &&
1288
+ gHost.mountingManager != nullptr &&
1289
+ gHost.mountingManager->requestCloseTopModal()) {
1290
+ return nil;
1291
+ }
1292
+
1293
+ // An app's declared shortcut, when
1294
+ // the responder chain will not
1295
+ // deliver it.
1296
+ //
1297
+ // `RnAppKitView keyDown:` is only
1298
+ // called on the first responder, so
1299
+ // an app whose shortcut holder is
1300
+ // the root -- nothing focuses it,
1301
+ // `focusable` only makes it
1302
+ // eligible -- never sees a press at
1303
+ // all. Only when nothing of ours is
1304
+ // in the chain: with a view focused
1305
+ // the ordinary path runs and this
1306
+ // must not run again, or a text
1307
+ // field would lose keys to a
1308
+ // window-level claim.
1309
+ if (basaltViewInResponderChain(event.window)) {
1310
+ return event;
1311
+ }
1312
+ if (gHost.main().focusManager != nullptr &&
1313
+ gHost.main().focusManager->deliverWindowKey(
1314
+ event)) {
1315
+ return nil;
1316
+ }
1317
+ return event;
1318
+ }];
1319
+
1320
+ // The display link needs a window, which the root now has.
1321
+ gHost.choreographer->attachToView(gHost.main().root);
1322
+
1323
+ if (gHost.moduleName.empty()) {
1324
+ NSLog(@"--- committing tree 1 from JS ---");
1325
+ callRenderFunction(1);
1326
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)),
1327
+ dispatch_get_main_queue(),
1328
+ ^{
1329
+ NSLog(@"--- committing tree 2 from JS ---");
1330
+ callRenderFunction(2);
1331
+ });
1332
+ }
1333
+
1334
+ // One clock for every instrument below, rather than one each.
1335
+ //
1336
+ // They used to start at 1500ms independently, which was invisible for as
1337
+ // long as no scenario used two at once -- and wrong the moment one did: a
1338
+ // run with both BASALT_TEST_TAP and BASALT_TEST_FOCUS interleaved them
1339
+ // instead of running the taps and then the keys. The GTK and Win32 hosts
1340
+ // have always threaded a single delay through; this is the third.
1341
+ __block int64_t scriptedDelayMs = 1500;
1342
+
1343
+ // BASALT_TEST_TAP: "x,y;x,y" -- synthesise taps a second apart, in
1344
+ // surface-root coordinates. Enters where AppKit's mouse handler would, so
1345
+ // it exercises hit testing and event delivery but not AppKit itself.
1346
+ //
1347
+ // It exists because the alternative is CGEvent, which needs accessibility
1348
+ // permission an automated run does not have. The GTK host has the same
1349
+ // escape hatch for the same reason, and the end-to-end suite on Linux uses
1350
+ // xdotool instead where it can.
1351
+ //
1352
+ // A point may name a window: "x,y@3" taps in the window whose surface is 3
1353
+ // rather than in the app's own. Without it there would be no way to reach a
1354
+ // second window at all -- each has its own touch dispatcher, which is the
1355
+ // whole point of them, and the main window's would happily hit-test a tree
1356
+ // that is not on screen.
1357
+ //
1358
+ // BASALT_TEST_SECONDARY_TAP takes the same spec and clicks the other
1359
+ // button. Its own variable rather than a suffix on a point, because the two
1360
+ // assert opposite things -- one presses what it lands on and the other must
1361
+ // not -- and a scenario that mixed them in one string would be harder to
1362
+ // read than to write.
1363
+ void (^scheduleTaps)(const char *, basalt::PointerButton) =
1364
+ ^(const char *taps, basalt::PointerButton button) {
1365
+ NSString *spec = [NSString stringWithUTF8String:taps];
1366
+ const BOOL secondary = button != basalt::PointerButton::Primary;
1367
+ NSString *name = secondary ? @"BASALT_TEST_SECONDARY_TAP" : @"BASALT_TEST_TAP";
1368
+ int64_t delayMs = scriptedDelayMs;
1369
+ for (NSString *entry in [spec componentsSeparatedByString:@";"]) {
1370
+ NSArray<NSString *> *halves = [entry componentsSeparatedByString:@"@"];
1371
+ // Defaults to the app's own window, so every spec written before
1372
+ // windows existed still means what it did.
1373
+ const facebook::react::SurfaceId surfaceId =
1374
+ halves.count > 1 ? (facebook::react::SurfaceId)halves[1].intValue : kSurfaceId;
1375
+ NSArray<NSString *> *parts = [halves[0] componentsSeparatedByString:@","];
1376
+ if (parts.count != 2) {
1377
+ continue;
1378
+ }
1379
+ const double x = parts[0].doubleValue;
1380
+ const double y = parts[1].doubleValue;
1381
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * NSEC_PER_MSEC),
1382
+ dispatch_get_main_queue(),
1383
+ ^{
1384
+ NSLog(@"%@: tapping (%.0f, %.0f) in window %d",
1385
+ name, x, y, (int)surfaceId);
1386
+ HostWindow *target = gHost.windowFor(surfaceId);
1387
+ if (target == nullptr) {
1388
+ NSLog(@"%@: no window %d", name, (int)surfaceId);
1389
+ return;
1390
+ }
1391
+ if (target->touchDispatcher != nullptr) {
1392
+ target->touchDispatcher->synthesiseTap(x, y, button);
1393
+ }
1394
+ });
1395
+ delayMs += 1000;
1396
+ }
1397
+ scriptedDelayMs = delayMs;
1398
+ };
1399
+
1400
+ if (const char *taps = getenv("BASALT_TEST_TAP")) {
1401
+ scheduleTaps(taps, basalt::PointerButton::Primary);
1402
+ }
1403
+ // The other button, which presses nothing and arrives as a pointer event.
1404
+ // See core/PointerButtons.h.
1405
+ if (const char *taps = getenv("BASALT_TEST_SECONDARY_TAP")) {
1406
+ scheduleTaps(taps, basalt::PointerButton::Secondary);
1407
+ }
1408
+
1409
+ // BASALT_TEST_HOVER: "x,y;x,y" -- the pointer moving with no button down,
1410
+ // a second apart, in surface-root coordinates. The only way to reach the
1411
+ // hover path from automation: a real cursor move is CGEvent again, and a
1412
+ // posted NSEventTypeMouseMoved does not drive a tracking area. A negative
1413
+ // point means the pointer left the surface.
1414
+ if (const char *hovers = getenv("BASALT_TEST_HOVER")) {
1415
+ NSString *spec = [NSString stringWithUTF8String:hovers];
1416
+ int64_t delayMs = scriptedDelayMs;
1417
+ for (NSString *point in [spec componentsSeparatedByString:@";"]) {
1418
+ NSArray<NSString *> *parts = [point componentsSeparatedByString:@","];
1419
+ if (parts.count != 2) {
1420
+ continue;
1421
+ }
1422
+ const double x = parts[0].doubleValue;
1423
+ const double y = parts[1].doubleValue;
1424
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * NSEC_PER_MSEC),
1425
+ dispatch_get_main_queue(),
1426
+ ^{
1427
+ NSLog(@"BASALT_TEST_HOVER: hovering (%.0f, %.0f)", x, y);
1428
+ if (gHost.main().touchDispatcher != nullptr) {
1429
+ gHost.main().touchDispatcher->synthesiseHover(x, y);
1430
+ }
1431
+ });
1432
+ delayMs += 1000;
1433
+ }
1434
+ scriptedDelayMs = delayMs;
1435
+ }
1436
+
1437
+ // BASALT_TEST_CLOSE_WINDOW: the surface id of a window to close the way a
1438
+ // person would -- its own close button, not the app asking. The two take
1439
+ // different paths through the host and only one of them can leave a record
1440
+ // whose window is gone, which is why it is worth being able to drive.
1441
+ if (const char *closing = getenv("BASALT_TEST_CLOSE_WINDOW")) {
1442
+ const auto surfaceId = (facebook::react::SurfaceId)atoi(closing);
1443
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, scriptedDelayMs * NSEC_PER_MSEC),
1444
+ dispatch_get_main_queue(),
1445
+ ^{
1446
+ NSLog(@"BASALT_TEST_CLOSE_WINDOW: closing window %d", (int)surfaceId);
1447
+ HostWindow *target = gHost.windowFor(surfaceId);
1448
+ if (target != nullptr) {
1449
+ // `performClose:`, not `close`: this is what a close
1450
+ // button sends, so windowWillClose: runs exactly as it
1451
+ // would for a person.
1452
+ [target->window performClose:nil];
1453
+ }
1454
+ });
1455
+ scriptedDelayMs += 1000;
1456
+ }
1457
+
1458
+ // BASALT_TEST_DROP: "x,y:path" -- a file dropped at that point, reported
1459
+ // the way the toolkit would report it. Entered below AppKit for the reason
1460
+ // the GTK host gives: a real drag needs a source outside this process.
1461
+ if (const char *drop = getenv("BASALT_TEST_DROP")) {
1462
+ const std::string spec(drop);
1463
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, scriptedDelayMs * NSEC_PER_MSEC),
1464
+ dispatch_get_main_queue(),
1465
+ ^{
1466
+ const size_t comma = spec.find(',');
1467
+ const size_t colon = spec.find(':', comma == std::string::npos ? 0 : comma);
1468
+ if (comma == std::string::npos || colon == std::string::npos) {
1469
+ NSLog(@"BASALT_TEST_DROP: expected x,y:path");
1470
+ return;
1471
+ }
1472
+ const double x = std::stod(spec.substr(0, comma));
1473
+ const double y = std::stod(spec.substr(comma + 1, colon - comma - 1));
1474
+ const std::string path = spec.substr(colon + 1);
1475
+
1476
+ const facebook::react::Tag found = basalt::dropTargetAt(
1477
+ gHost.main().root, x, y, basalt::DropAcceptsFiles);
1478
+ NSLog(@"BASALT_TEST_DROP: %s at %g,%g onto tag %d",
1479
+ path.c_str(), x, y, (int)found);
1480
+ if (found == 0) {
1481
+ return;
1482
+ }
1483
+ basalt::DropEvent event;
1484
+ event.tag = found;
1485
+ event.phase = basalt::DropPhase::Drop;
1486
+ event.x = x;
1487
+ event.y = y;
1488
+ event.payload.files.push_back(path);
1489
+ basalt::reportDrop(event);
1490
+ });
1491
+ scriptedDelayMs += 1000;
1492
+ }
1493
+
1494
+ // BASALT_TEST_QUIT: ask the application to quit the way Cmd-Q does, so a
1495
+ // quit an app refuses can be driven from a script. Not the same thing as
1496
+ // BASALT_QUIT_AFTER_MS, which is the harness ending the process and is
1497
+ // deliberately not refusable -- this one goes through
1498
+ // applicationShouldTerminate: and is.
1499
+ //
1500
+ // A count rather than a flag, because one ask cannot show the interesting
1501
+ // half: an app that refuses is proved by the process still being here,
1502
+ // and an app that then agrees is proved by it going. Asks are a second
1503
+ // apart, like every other scripted instrument.
1504
+ if (const char *asks = getenv("BASALT_TEST_QUIT")) {
1505
+ const long times = std::max(1L, strtol(asks, nullptr, 10));
1506
+ for (long ask = 0; ask < times; ask++) {
1507
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, scriptedDelayMs * NSEC_PER_MSEC),
1508
+ dispatch_get_main_queue(),
1509
+ ^{
1510
+ NSLog(@"BASALT_TEST_QUIT: asking the application to quit");
1511
+ [NSApp terminate:nil];
1512
+ });
1513
+ scriptedDelayMs += 1000;
1514
+ }
1515
+ }
1516
+
1517
+ // BASALT_TEST_FOCUS: keyboard actions separated by ';' -- `tab`,
1518
+ // `shift-tab`, `activate`, `escape` and `devmenu`, each fired a second
1519
+ // apart.
1520
+ //
1521
+ // The same reason the other instruments exist, one step further out. A real
1522
+ // Tab needs a window the window server considers key, which an automated
1523
+ // run does not reliably have. This enters at AppKitFocusManager, so it
1524
+ // exercises AppKit's own key-view loop, the focus and blur events and the
1525
+ // click dispatch, and skips only the delivery of the keystroke itself.
1526
+ // BASALT_TEST_KEY: a declared shortcut, pressed. Enters after the NSEvent
1527
+ // translation rather than before it -- see core/TestKeys.h for what that
1528
+ // does and does not prove.
1529
+ for (const basalt::KeyCombination &pressed : basalt::scriptedKeyPresses()) {
1530
+ const basalt::KeyCombination combination = pressed;
1531
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, scriptedDelayMs * NSEC_PER_MSEC),
1532
+ dispatch_get_main_queue(),
1533
+ ^{
1534
+ NSLog(@"BASALT_TEST_KEY: %s", combination.key.c_str());
1535
+ if (gHost.main().focusManager != nullptr) {
1536
+ gHost.main().focusManager->deliverTestKey(combination);
1537
+ }
1538
+ });
1539
+ scriptedDelayMs += 400;
1540
+ }
1541
+
1542
+ if (const char *focus = getenv("BASALT_TEST_FOCUS")) {
1543
+ NSString *spec = [NSString stringWithUTF8String:focus];
1544
+ int64_t delayMs = scriptedDelayMs;
1545
+ for (NSString *raw in [spec componentsSeparatedByString:@";"]) {
1546
+ NSString *action = [raw stringByTrimmingCharactersInSet:
1547
+ [NSCharacterSet whitespaceCharacterSet]];
1548
+ if (action.length == 0) {
1549
+ continue;
1550
+ }
1551
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * NSEC_PER_MSEC),
1552
+ dispatch_get_main_queue(),
1553
+ ^{
1554
+ NSLog(@"BASALT_TEST_FOCUS: %@", action);
1555
+ if (gHost.main().focusManager == nullptr &&
1556
+ ![action isEqualToString:@"escape"] &&
1557
+ ![action isEqualToString:@"devmenu"]) {
1558
+ return;
1559
+ }
1560
+ if ([action isEqualToString:@"tab"]) {
1561
+ gHost.main().focusManager->moveFocus(true);
1562
+ } else if ([action isEqualToString:@"shift-tab"]) {
1563
+ gHost.main().focusManager->moveFocus(false);
1564
+ } else if ([action isEqualToString:@"activate"]) {
1565
+ gHost.main().focusManager->activateFocused();
1566
+ } else if ([action isEqualToString:@"devmenu"]) {
1567
+ // Also not a focus action. Same instrument for the
1568
+ // same reason: a key that needs nothing focused to
1569
+ // arrive. What it opens is answered by
1570
+ // BASALT_TEST_MENU; see core/TestDialog.h.
1571
+ basalt::showDevMenu(gHost.reactHost.get());
1572
+ } else if ([action isEqualToString:@"escape"]) {
1573
+ // Not a focus action, and here anyway: this is the
1574
+ // instrument for "a key was pressed and nothing on
1575
+ // the window has to be focused for it to arrive",
1576
+ // which is exactly what Escape closing a <Modal> is.
1577
+ gHost.mountingManager->requestCloseTopModal();
1578
+ } else {
1579
+ NSLog(@"BASALT_TEST_FOCUS: unknown action \"%@\"", action);
1580
+ }
1581
+ });
1582
+ delayMs += 1000;
1583
+ }
1584
+ scriptedDelayMs = delayMs;
1585
+ }
1586
+
1587
+ // BASALT_TEST_DRAG: "x1,y1,x2,y2" -- one press, twenty moves and a release,
1588
+ // for the gestures a tap cannot reach. See synthesiseDrag.
1589
+ if (const char *drag = getenv("BASALT_TEST_DRAG")) {
1590
+ NSArray<NSString *> *parts =
1591
+ [[NSString stringWithUTF8String:drag] componentsSeparatedByString:@","];
1592
+ if (parts.count == 4) {
1593
+ const double fromX = parts[0].doubleValue;
1594
+ const double fromY = parts[1].doubleValue;
1595
+ const double toX = parts[2].doubleValue;
1596
+ const double toY = parts[3].doubleValue;
1597
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)1500 * NSEC_PER_MSEC),
1598
+ dispatch_get_main_queue(),
1599
+ ^{
1600
+ NSLog(@"BASALT_TEST_DRAG: (%.0f, %.0f) -> (%.0f, %.0f)",
1601
+ fromX,
1602
+ fromY,
1603
+ toX,
1604
+ toY);
1605
+ if (gHost.main().touchDispatcher != nullptr) {
1606
+ gHost.main().touchDispatcher->synthesiseDrag(fromX, fromY, toX, toY, 20);
1607
+ }
1608
+ });
1609
+ }
1610
+ }
1611
+
1612
+ // BASALT_TEST_CLICK: the same "x,y;x,y", but as real NSEvents posted to the
1613
+ // window rather than as calls into the dispatcher.
1614
+ //
1615
+ // The difference from BASALT_TEST_TAP is the whole point: a synthesised tap
1616
+ // proves hit testing and delivery to JavaScript, and proves nothing about
1617
+ // whether AppKit routes a click to these views at all. This posts events
1618
+ // through the application's own queue, which exercises NSView's hit
1619
+ // testing, the responder chain and mouseDown:/mouseUp:.
1620
+ //
1621
+ // In-process, so it needs none of the accessibility permission CGEvent
1622
+ // would -- which is what makes it usable in automation where a real click
1623
+ // is not.
1624
+ if (const char *clicks = getenv("BASALT_TEST_CLICK")) {
1625
+ NSString *spec = [NSString stringWithUTF8String:clicks];
1626
+ int64_t delayMs = scriptedDelayMs;
1627
+ for (NSString *point in [spec componentsSeparatedByString:@";"]) {
1628
+ NSArray<NSString *> *parts = [point componentsSeparatedByString:@","];
1629
+ if (parts.count != 2) {
1630
+ continue;
1631
+ }
1632
+ const CGFloat x = parts[0].doubleValue;
1633
+ const CGFloat y = parts[1].doubleValue;
1634
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * NSEC_PER_MSEC),
1635
+ dispatch_get_main_queue(),
1636
+ ^{
1637
+ NSLog(@"BASALT_TEST_CLICK: clicking (%.0f, %.0f)", x, y);
1638
+ // Through the root, which converts out of the flipped
1639
+ // top-left space every coordinate here is in and into
1640
+ // the window's bottom-left one.
1641
+ const NSPoint inWindow = [gHost.main().root convertPoint:NSMakePoint(x, y)
1642
+ toView:nil];
1643
+ for (NSEventType type :
1644
+ {NSEventTypeLeftMouseDown, NSEventTypeLeftMouseUp}) {
1645
+ NSEvent *event =
1646
+ [NSEvent mouseEventWithType:type
1647
+ location:inWindow
1648
+ modifierFlags:0
1649
+ timestamp:NSProcessInfo.processInfo.systemUptime
1650
+ windowNumber:gHost.main().window.windowNumber
1651
+ context:nil
1652
+ eventNumber:0
1653
+ clickCount:1
1654
+ pressure:type == NSEventTypeLeftMouseDown ? 1 : 0];
1655
+ [NSApp postEvent:event atStart:NO];
1656
+ }
1657
+ });
1658
+ delayMs += 1000;
1659
+ }
1660
+ scriptedDelayMs = delayMs;
1661
+ }
1662
+
1663
+ // BASALT_TEST_SCROLL: "x,y,lines;x,y,lines" -- scroll wheel notches over a
1664
+ // point, a second apart, in surface-root coordinates. Positive scrolls
1665
+ // down, as a contentOffset does.
1666
+ //
1667
+ // The event is a real NSEvent, built the way a wheel builds one, and it is
1668
+ // handed to the view this project's own hit test finds. So it proves the
1669
+ // whole path below AppKit -- scrollWheel:, the handler lookup, the offset,
1670
+ // the clamp, the state write and onScroll -- and does not prove that AppKit
1671
+ // routes a wheel to that view, which is ordinary responder-chain behaviour
1672
+ // that `[super scrollWheel:]` takes part in.
1673
+ if (const char *scrolls = getenv("BASALT_TEST_SCROLL")) {
1674
+ NSString *spec = [NSString stringWithUTF8String:scrolls];
1675
+ int64_t delayMs = scriptedDelayMs;
1676
+ for (NSString *step in [spec componentsSeparatedByString:@";"]) {
1677
+ NSArray<NSString *> *parts = [step componentsSeparatedByString:@","];
1678
+ if (parts.count != 3) {
1679
+ continue;
1680
+ }
1681
+ const CGFloat x = parts[0].doubleValue;
1682
+ const CGFloat y = parts[1].doubleValue;
1683
+ const int32_t lines = (int32_t)parts[2].intValue;
1684
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * NSEC_PER_MSEC),
1685
+ dispatch_get_main_queue(),
1686
+ ^{
1687
+ RnAppKitView *target = RnAppKitHitTest(gHost.main().root, x, y);
1688
+ if (target == nil) {
1689
+ NSLog(@"BASALT_TEST_SCROLL: nothing at (%.0f, %.0f)", x, y);
1690
+ return;
1691
+ }
1692
+ NSLog(@"BASALT_TEST_SCROLL: %d lines over tag %ld",
1693
+ lines, (long)target.rnTag);
1694
+ // Negative because AppKit's positive Y is a scroll up,
1695
+ // and this is spelled the way contentOffset reads.
1696
+ CGEventRef wheel = CGEventCreateScrollWheelEvent(
1697
+ nullptr, kCGScrollEventUnitLine, 1, -lines);
1698
+ if (wheel == nullptr) {
1699
+ return;
1700
+ }
1701
+ NSEvent *event = [NSEvent eventWithCGEvent:wheel];
1702
+ CFRelease(wheel);
1703
+ if (event != nil) {
1704
+ [target scrollWheel:event];
1705
+ }
1706
+ });
1707
+ delayMs += 1000;
1708
+ }
1709
+ scriptedDelayMs = delayMs;
1710
+ }
1711
+
1712
+ // BASALT_TEST_TYPE: text to insert into whatever field has focus, for the
1713
+ // same reason BASALT_TEST_TAP exists -- synthesising a real key event means
1714
+ // CGEvent and accessibility permission an automated run does not have.
1715
+ //
1716
+ // It inserts through the field editor, so it skips the key-down path and
1717
+ // the input method and exercises everything above them: the delegate, the
1718
+ // event emitter, React's re-render, and the controlled value coming back
1719
+ // down. The GTK host's version enters through GtkEditable for the same
1720
+ // reason, and the Linux end-to-end suite types with xdotool where it can.
1721
+ if (const char *text = getenv("BASALT_TEST_TYPE")) {
1722
+ NSString *typed = [NSString stringWithUTF8String:text];
1723
+ const char *afterMs = getenv("BASALT_TEST_TYPE_AFTER_MS");
1724
+ const int64_t delayMs = afterMs != nullptr ? strtoll(afterMs, nullptr, 10) : 2500;
1725
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * NSEC_PER_MSEC),
1726
+ dispatch_get_main_queue(),
1727
+ ^{
1728
+ NSResponder *responder = gHost.main().window.firstResponder;
1729
+ if (![responder isKindOfClass:[NSText class]]) {
1730
+ NSLog(@"BASALT_TEST_TYPE: no text field has focus");
1731
+ return;
1732
+ }
1733
+ NSLog(@"BASALT_TEST_TYPE: typing \"%@\"", typed);
1734
+ NSText *editor = (NSText *)responder;
1735
+ [editor insertText:typed];
1736
+ });
1737
+ }
1738
+
1739
+ // What the schedule above came to, so BASALT_QUIT_WHEN_SETTLED waits for the
1740
+ // last of it rather than only for the first mount. The typing is the one
1741
+ // instrument here that does not fold into scriptedDelayMs -- it has its own
1742
+ // default and its own override -- so the total is the later of the two.
1743
+ // See core/TestSettle.h.
1744
+ {
1745
+ int64_t scriptedEndsIn = scriptedDelayMs;
1746
+ if (getenv("BASALT_TEST_TYPE") != nullptr) {
1747
+ const char *typeAfter = getenv("BASALT_TEST_TYPE_AFTER_MS");
1748
+ const int64_t typeAt = typeAfter != nullptr ? strtoll(typeAfter, nullptr, 10) : 2500;
1749
+ scriptedEndsIn = std::max(scriptedEndsIn, typeAt);
1750
+ }
1751
+ basalt::noteScriptedInputEndsInMs(scriptedEndsIn);
1752
+ }
1753
+
1754
+ // BASALT_SNAPSHOT: render what is actually on screen to a PNG on the way
1755
+ // out. The tree dump above says what was mounted; this says what it looks
1756
+ // like, and the two fail differently -- a correct tree can still paint
1757
+ // nothing if the layer or the window is wrong.
1758
+ if (const char *snapshot = getenv("BASALT_SNAPSHOT")) {
1759
+ NSString *path = [NSString stringWithUTF8String:snapshot];
1760
+ // BASALT_SNAPSHOT_AFTER_MS: when to take it. A snapshot is only worth
1761
+ // anything if it lands after whatever is being tested, and the taps above
1762
+ // start at 1500ms, so the default would catch the app before its first
1763
+ // press.
1764
+ const char *after = getenv("BASALT_SNAPSHOT_AFTER_MS");
1765
+ const int64_t delayMs = after != nullptr ? strtoll(after, nullptr, 10) : 1000;
1766
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayMs * NSEC_PER_MSEC),
1767
+ dispatch_get_main_queue(),
1768
+ ^{
1769
+ if (RnAppKitWriteSnapshot(gHost.main().root, path)) {
1770
+ NSLog(@"wrote a snapshot to %@", path);
1771
+ } else {
1772
+ NSLog(@"could not write a snapshot to %@", path);
1773
+ }
1774
+ });
1775
+ }
1776
+
1777
+ // Same clean exit on SIGINT or SIGTERM, so Ctrl-C and `kill` shut the
1778
+ // runtime down instead of dropping it -- the GTK host has had this since
1779
+ // phase 32 and this one never did. The default disposition kills the
1780
+ // process where it stands, skipping applicationWillTerminate and so
1781
+ // stopAllSurfaces, the teardown ordering and the tree dump.
1782
+ //
1783
+ // A dispatch source is the safe form, and the counterpart of GTK's
1784
+ // g_unix_signal_add: the handler does not run in signal context, it wakes
1785
+ // the main queue and runs there, so ordinary Cocoa calls are allowed. The
1786
+ // signal must be ignored first, because the dispatch source observes the
1787
+ // signal rather than replacing its disposition -- without this the process
1788
+ // still dies before the block runs.
1789
+ static NSMutableArray *sSignalSources = [NSMutableArray array];
1790
+ for (int signo : {SIGINT, SIGTERM}) {
1791
+ signal(signo, SIG_IGN);
1792
+ dispatch_source_t source =
1793
+ dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, (uintptr_t)signo, 0,
1794
+ dispatch_get_main_queue());
1795
+ dispatch_source_set_event_handler(source, ^{
1796
+ NSLog(@"signal received; quitting");
1797
+ [NSApp terminate:nil];
1798
+ });
1799
+ dispatch_resume(source);
1800
+ // Held in a static, because under ARC a dispatch_source_t is an object
1801
+ // like any other: let it go out of scope at the end of this iteration and
1802
+ // it is released and torn down before it can ever fire, which looks
1803
+ // exactly like the signal not being caught at all.
1804
+ [sSignalSources addObject:source];
1805
+ }
1806
+
1807
+ // BASALT_QUIT_AFTER_MS: quitting on a timer is the only way to exercise the
1808
+ // shutdown path in automation. The window cannot be closed from a script,
1809
+ // and killing the process skips applicationWillTerminate entirely, so
1810
+ // stopAllSurfaces and the teardown ordering below it would never run.
1811
+ if (const char *quitAfter = getenv("BASALT_QUIT_AFTER_MS")) {
1812
+ const long ms = strtol(quitAfter, nullptr, 10);
1813
+ if (ms > 0) {
1814
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)ms * NSEC_PER_MSEC),
1815
+ dispatch_get_main_queue(),
1816
+ ^{
1817
+ NSLog(@"BASALT_QUIT_AFTER_MS elapsed; quitting");
1818
+ endAnyOpenSheets();
1819
+ gQuittingForTest = true;
1820
+ [NSApp terminate:nil];
1821
+ });
1822
+ }
1823
+ }
1824
+
1825
+ // BASALT_TEST_QUIT_FILE: the same quit, at a moment the harness picks
1826
+ // rather than on a budget fixed before launch. `terminate:` either way, so
1827
+ // applicationWillTerminate runs and the view tree is dumped -- which is the
1828
+ // whole reason for asking rather than killing. See core/TestQuitFile.h.
1829
+ //
1830
+ // gQuittingForTest for the reason the timed quit sets it: this is the
1831
+ // harness ending the run, and an app that intercepts its own quit would
1832
+ // otherwise refuse it and hang. See applicationShouldTerminate:.
1833
+ //
1834
+ // Two instruments, one timer, because they want the same thing at different
1835
+ // moments: the quit file is the harness deciding, BASALT_QUIT_WHEN_SETTLED is
1836
+ // the app noticing it has nothing left to do. See core/TestSettle.h.
1837
+ if (basalt::testQuitFilePath().has_value() || basalt::quitWhenSettledMs().has_value()) {
1838
+ [NSTimer scheduledTimerWithTimeInterval:(double)basalt::kTestQuitFilePollMs / 1000.0
1839
+ repeats:YES
1840
+ block:^(NSTimer *timer) {
1841
+ const char *why = nullptr;
1842
+ if (basalt::testQuitFileAppeared()) {
1843
+ why = basalt::kTestQuitFileMessage;
1844
+ } else if (basalt::hasSettled()) {
1845
+ why = basalt::kQuitWhenSettledMessage;
1846
+ } else {
1847
+ return;
1848
+ }
1849
+ [timer invalidate];
1850
+ NSLog(@"%s", why);
1851
+ endAnyOpenSheets();
1852
+ gQuittingForTest = true;
1853
+ [NSApp terminate:nil];
1854
+ }];
1855
+ }
1856
+
1857
+ [NSApp run];
1858
+ }
1859
+ return 0;
1860
+ }