basalt-win32 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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runWindows.js +35 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +398 -0
  5. package/native/tests/test_win32_accessibility.cpp +271 -0
  6. package/native/tests/test_win32_clipboard.cpp +121 -0
  7. package/native/tests/test_win32_focus.cpp +195 -0
  8. package/native/tests/test_win32_hittest.cpp +410 -0
  9. package/native/tests/test_win32_image.cpp +239 -0
  10. package/native/tests/test_win32_imageloader.cpp +208 -0
  11. package/native/tests/test_win32_input.cpp +361 -0
  12. package/native/tests/test_win32_key_events.cpp +84 -0
  13. package/native/tests/test_win32_mounting.cpp +410 -0
  14. package/native/tests/test_win32_paint.cpp +505 -0
  15. package/native/tests/test_win32_scrollview.cpp +385 -0
  16. package/native/tests/test_win32_text.cpp +258 -0
  17. package/native/tests/test_win32_textinput.cpp +609 -0
  18. package/native/tests/test_win32_titlebar.cpp +111 -0
  19. package/native/tests/test_win32_titlebar_actions.cpp +62 -0
  20. package/native/tests/test_win32_view.cpp +222 -0
  21. package/native/win32/ColorSchemeWin32.cpp +56 -0
  22. package/native/win32/ComponentRegistryWin32.cpp +124 -0
  23. package/native/win32/DirectWriteLayout.cpp +116 -0
  24. package/native/win32/DirectWriteLayout.h +36 -0
  25. package/native/win32/DirectWriteLayoutManager.cpp +124 -0
  26. package/native/win32/FontRegistryDirectWrite.cpp +117 -0
  27. package/native/win32/PlatformServicesWin32.cpp +643 -0
  28. package/native/win32/RnWin32Accessible.cpp +258 -0
  29. package/native/win32/RnWin32Accessible.h +91 -0
  30. package/native/win32/RnWin32Image.cpp +265 -0
  31. package/native/win32/RnWin32Image.h +93 -0
  32. package/native/win32/RnWin32TextLayout.cpp +310 -0
  33. package/native/win32/RnWin32TextLayout.h +160 -0
  34. package/native/win32/RnWin32View.cpp +1281 -0
  35. package/native/win32/RnWin32View.h +506 -0
  36. package/native/win32/Win32AnimationChoreographer.cpp +76 -0
  37. package/native/win32/Win32AnimationChoreographer.h +61 -0
  38. package/native/win32/Win32Clip.h +173 -0
  39. package/native/win32/Win32DropTarget.cpp +567 -0
  40. package/native/win32/Win32DropTarget.h +42 -0
  41. package/native/win32/Win32Focus.cpp +262 -0
  42. package/native/win32/Win32Focus.h +109 -0
  43. package/native/win32/Win32ImageLoader.cpp +167 -0
  44. package/native/win32/Win32ImageLoader.h +104 -0
  45. package/native/win32/Win32KeyEvents.cpp +126 -0
  46. package/native/win32/Win32KeyEvents.h +20 -0
  47. package/native/win32/Win32MenuBar.cpp +352 -0
  48. package/native/win32/Win32MenuBar.h +16 -0
  49. package/native/win32/Win32MountingManager.cpp +727 -0
  50. package/native/win32/Win32MountingManager.h +203 -0
  51. package/native/win32/Win32Packaging.cpp +161 -0
  52. package/native/win32/Win32Packaging.h +56 -0
  53. package/native/win32/Win32RunLoopObserver.cpp +48 -0
  54. package/native/win32/Win32RunLoopObserver.h +42 -0
  55. package/native/win32/Win32ScrollView.cpp +515 -0
  56. package/native/win32/Win32ScrollView.h +208 -0
  57. package/native/win32/Win32SkiaContext.cpp +221 -0
  58. package/native/win32/Win32SkiaContext.h +53 -0
  59. package/native/win32/Win32SkiaModule.cpp +80 -0
  60. package/native/win32/Win32SkiaModule.h +50 -0
  61. package/native/win32/Win32SkiaPeer.cpp +358 -0
  62. package/native/win32/Win32SkiaPeer.h +66 -0
  63. package/native/win32/Win32SkiaUnicode.cpp +42 -0
  64. package/native/win32/Win32Snapshot.cpp +201 -0
  65. package/native/win32/Win32Snapshot.h +66 -0
  66. package/native/win32/Win32Strings.h +54 -0
  67. package/native/win32/Win32TextInput.cpp +811 -0
  68. package/native/win32/Win32TextInput.h +232 -0
  69. package/native/win32/Win32TitleBar.cpp +562 -0
  70. package/native/win32/Win32TitleBar.h +146 -0
  71. package/native/win32/Win32TitleBarLayout.cpp +46 -0
  72. package/native/win32/Win32TitleBarLayout.h +58 -0
  73. package/native/win32/Win32TouchDispatcher.cpp +443 -0
  74. package/native/win32/Win32TouchDispatcher.h +153 -0
  75. package/native/win32/Win32UiThread.h +27 -0
  76. package/native/win32/Win32WindowControl.cpp +329 -0
  77. package/native/win32/Win32WindowModule.cpp +209 -0
  78. package/native/win32/Win32WindowModule.h +72 -0
  79. package/native/win32/demo_layout_win32.cpp +232 -0
  80. package/native/win32/main_win32.cpp +2481 -0
  81. package/native/win32/mount_harness_win32.cpp +316 -0
  82. package/package.json +26 -0
  83. package/react-native.config.js +22 -0
@@ -0,0 +1,2481 @@
1
+ // basalt-core — the Windows host process.
2
+ //
3
+ // Constructs a ReactHost, loads a script into Hermes, and runs one surface in
4
+ // an HWND. Everything on screen from here on is produced by Fabric: the
5
+ // mutation stream arrives through Win32MountingManager exactly as it does on
6
+ // iOS and Android, and nothing hand-builds a ShadowViewMutation.
7
+ //
8
+ // The counterpart of gtk/main_gtk.cpp and appkit/main_appkit.mm, and
9
+ // deliberately the same shape. The pieces a host has to supply, and where each
10
+ // comes from:
11
+ //
12
+ // IMountingManager Win32MountingManager (this repo)
13
+ // RunLoopObserverManager ReactCxxPlatform (event beat)
14
+ // the beat itself Win32RunLoopObserver (this repo, the loop)
15
+ // AnimationChoreographer Win32AnimationChoreographer (this repo, a timer)
16
+ // ContextContainer http + websocket client factories, below
17
+ // ComponentRegistryFactory ComponentRegistryWin32 (via the mounting manager)
18
+ // FontRegistry FontRegistryDirectWrite (this repo, link-time seam)
19
+ // input Win32TouchDispatcher (this repo, hostProc)
20
+ //
21
+ // Threading: this thread owns the window and the message loop. ReactHost spins
22
+ // up its own JS thread, and every mount is marshalled back here by
23
+ // Win32MountingManager through postToUiThread. Nothing below touches a view off
24
+ // this thread.
25
+ //
26
+ // What this host can render is what Win32MountingManager can mount: <View>,
27
+ // <Text>, <Image>, <ScrollView> and <TextInput> -- the same five as the other
28
+ // two desktops. `js/demo.js` -- a surface driven straight through
29
+ // nativeFabricUIManager, with no React and no react-native JavaScript -- is
30
+ // still the bundle this runs by default, because it is what an argumentless run
31
+ // has always meant here; an ordinary React app runs from a bundle and a module
32
+ // name.
33
+ //
34
+ // One thing below is not like the other hosts, and it is <TextInput>. Its peer
35
+ // is a real EDIT control, which is a child *window*, so the host has to place
36
+ // it, forward its notifications, and answer its colour questions. See
37
+ // win32/Win32TextInput.h.
38
+
39
+ #include "Win32AnimationChoreographer.h"
40
+ #include "AppIdentity.h"
41
+ #include "AppPaths.h"
42
+ #include "DevMenu.h"
43
+ #include "DialogModule.h"
44
+ #include "MenuModel.h"
45
+ #include "MenuModule.h"
46
+ #include "WindowsModule.h"
47
+ #include "TestKeys.h"
48
+ #include "TestQuitFile.h"
49
+ #include "TestSettle.h"
50
+ #include "Win32MountingManager.h"
51
+
52
+ #include <react/io/ImageLoaderModule.h>
53
+ #include "Win32MenuBar.h"
54
+ #ifdef BASALT_HAS_SKIA
55
+ #include "Win32SkiaModule.h"
56
+ #endif
57
+ #include "Win32Packaging.h"
58
+ #include "PointerButtons.h"
59
+ #include "WindowControl.h"
60
+ #include "WindowHost.h"
61
+ #include "Win32RunLoopObserver.h"
62
+ #include "Win32Snapshot.h"
63
+ #include "Win32Strings.h"
64
+ #include "LogBoxSurface.h"
65
+
66
+ #include <react/renderer/animated/NativeAnimatedNodesManagerProvider.h>
67
+ #include "Win32Focus.h"
68
+ #include "Win32TouchDispatcher.h"
69
+ #include "Win32UiThread.h"
70
+ #include "DragAndDrop.h"
71
+ #include "Win32DropTarget.h"
72
+ #include "Win32TitleBar.h"
73
+ #include "Win32WindowModule.h"
74
+ #include "RnWin32View.h"
75
+
76
+ #include "AppearanceModule.h"
77
+ #include "BlobModule.h"
78
+ #include "ColorScheme.h"
79
+ #include "CoreModules.h"
80
+ #include "DevBundle.h"
81
+ #include "ExpoModules.h"
82
+ #include "ExpoRuntime.h"
83
+ #include "TurboModuleProxy.h"
84
+ #include "GestureHandlerModule.h"
85
+ #include "PlatformConstantsModule.h"
86
+ #include "PlatformServices.h"
87
+ #include "ReanimatedModule.h"
88
+ #include "SourceCodeModule.h"
89
+ #include "StatusBarModule.h"
90
+ #include "JsRuntimeAccess.h"
91
+ #include "PackageModules.h"
92
+ #include "UIManagerAccess.h"
93
+ #include "WorkletsModule.h"
94
+
95
+ #include <glog/logging.h>
96
+ #include <jsi/jsi.h>
97
+ #include <logger/react_native_log.h>
98
+ #include <react/featureflags/ReactNativeFeatureFlags.h>
99
+ #include <react/featureflags/ReactNativeFeatureFlagsDefaults.h>
100
+ #include <react/http/IHttpClient.h>
101
+ #include <react/http/IWebSocketClient.h>
102
+ #include <react/logging/DefaultOnJsErrorHandler.h>
103
+ #include <react/renderer/core/LayoutConstraints.h>
104
+ #include <react/renderer/core/LayoutContext.h>
105
+ #include <react/runtime/ReactHost.h>
106
+ #include <react/runtime/ReactInstanceConfig.h>
107
+ #include <react/utils/ContextContainer.h>
108
+ #include <react/utils/RunLoopObserverManager.h>
109
+
110
+ #include <windows.h>
111
+ // GET_X_LPARAM. Its own header, and not pulled in by WIN32_LEAN_AND_MEAN.
112
+ #include <windowsx.h>
113
+
114
+ #include <d2d1.h>
115
+ #include <wrl/client.h>
116
+
117
+ #include <algorithm>
118
+ #include <cstdio>
119
+ #include <cstdlib>
120
+ #include <cwchar>
121
+ #include <exception>
122
+ #include <memory>
123
+ #include <string>
124
+ #include <vector>
125
+
126
+ using Microsoft::WRL::ComPtr;
127
+ using facebook::react::ContextContainer;
128
+ using facebook::react::Float;
129
+ using facebook::react::LayoutConstraints;
130
+ using facebook::react::LayoutContext;
131
+ using facebook::react::LayoutDirection;
132
+ using facebook::react::ReactHost;
133
+ using facebook::react::ReactInstanceConfig;
134
+ using facebook::react::RunLoopObserverManager;
135
+ using facebook::react::SurfaceId;
136
+ using basalt::win32::RnWin32View;
137
+
138
+ namespace {
139
+
140
+ // In Fabric a SurfaceId *is* the root shadow node's tag, which is what lets the
141
+ // surface root live in the mounting manager's registry like any other view.
142
+ constexpr SurfaceId kSurfaceId = 1;
143
+
144
+ // React Native's error inspector, which is a surface of its own -- registered
145
+ // by AppRegistry under the name "LogBox", exactly as an app registers its own
146
+ // component. Started over the app's when NativeLogBox.show() asks; see
147
+ // core/LogBoxSurface.h.
148
+ constexpr SurfaceId kLogBoxSurfaceId = 2;
149
+
150
+ constexpr int kInitialWidth = 900;
151
+ constexpr int kInitialHeight = 700;
152
+
153
+ // The entry point for the *scriptless* mode, where the bundle is a hand-written
154
+ // script talking to nativeFabricUIManager directly rather than a React app.
155
+ // See js/demo.js. Only used when no module name is given.
156
+ constexpr const char *kRenderFunctionName = "basaltRender";
157
+
158
+ constexpr UINT_PTR kSecondTreeTimer = 100;
159
+
160
+ // BASALT_QUIT_AFTER_MS's timer. A constant rather than the literal 101 it used
161
+ // to be written as at its one call site: the spinner timer below was given 101
162
+ // too, and `SetTimer` with an id that is already in use *replaces* the timer
163
+ // rather than failing. An app with an <ActivityIndicator> in it therefore
164
+ // silently cancelled its own shutdown, and the only symptom was a host that
165
+ // never exited.
166
+ constexpr UINT_PTR kQuitAfterTimer = 101;
167
+
168
+ // One window, and the surface in it.
169
+ //
170
+ // A window is a surface: a surface is what has a size, a layout context and a
171
+ // root shadow node, and two windows sharing one would be two windows sharing a
172
+ // layout. So the id a window is known by *is* its surface id. See
173
+ // core/WindowHost.h.
174
+ //
175
+ // The render target is here rather than on Host because a
176
+ // ID2D1HwndRenderTarget belongs to one HWND -- painting a second window through
177
+ // the first one's would draw into the first one.
178
+ struct HostWindow {
179
+ facebook::react::SurfaceId surfaceId{0};
180
+ HWND window{nullptr};
181
+ RnWin32View *root{nullptr};
182
+ // Whether this press has already been offered as a drag out; see
183
+ // WM_MOUSEMOVE.
184
+ bool dragAsked{false};
185
+ ComPtr<ID2D1HwndRenderTarget> target;
186
+ std::unique_ptr<basalt::Win32TouchDispatcher> touchDispatcher;
187
+ std::unique_ptr<basalt::Win32FocusManager> focusManager;
188
+ // The app is closing this window, so WM_DESTROY is not news.
189
+ //
190
+ // Both closes end in `DestroyWindow` and so both raise WM_DESTROY, and the
191
+ // handler there is the one that tells JavaScript a window closed *itself* and
192
+ // tears the record down. Without this, an app closing a window ran that too:
193
+ // a second `stopSurface`, a `basaltWindowClosed` nobody closed, and a second
194
+ // deferred teardown that destroyed an already-destroyed root -- which is an
195
+ // access violation at exit rather than a warning.
196
+ //
197
+ // AppKit has the same hazard and answers it the same way, by nilling the
198
+ // delegate before `close`. Windows has no delegate to nil.
199
+ bool closingFromApp{false};
200
+ };
201
+
202
+ struct Host {
203
+ // Every window, main one first. Never empty once the app has started.
204
+ std::vector<std::unique_ptr<HostWindow>> windows;
205
+
206
+ // The window the app was started in. Shorthand for windows.front(), which is
207
+ // what every part of this host that is not yet per-window uses.
208
+ // An empty window with null fields before there is a real one, rather than
209
+ // dereferencing an empty vector. Several things here run before the window
210
+ // exists -- a repaint request, the spinner timer -- and each one already
211
+ // guards on `window == nullptr`, which is exactly what this keeps true.
212
+ HostWindow &main() {
213
+ static HostWindow none;
214
+ return windows.empty() ? none : *windows.front();
215
+ }
216
+
217
+ HostWindow *windowFor(facebook::react::SurfaceId surfaceId) {
218
+ for (const auto &candidate : windows) {
219
+ if (candidate->surfaceId == surfaceId) {
220
+ return candidate.get();
221
+ }
222
+ }
223
+ return nullptr;
224
+ }
225
+
226
+ // Which window a message is for. `hostProc` is one procedure for every window
227
+ // this host makes, so every message starts by asking this.
228
+ HostWindow *windowFor(HWND hwnd) {
229
+ for (const auto &candidate : windows) {
230
+ if (candidate->window == hwnd) {
231
+ return candidate.get();
232
+ }
233
+ }
234
+ return nullptr;
235
+ }
236
+
237
+ ComPtr<ID2D1Factory> d2dFactory;
238
+
239
+ std::shared_ptr<basalt::Win32MountingManager> mountingManager;
240
+ std::shared_ptr<RunLoopObserverManager> runLoopObserverManager;
241
+ std::shared_ptr<basalt::Win32AnimationChoreographer> choreographer;
242
+ std::unique_ptr<ReactHost> reactHost;
243
+ // The error inspector's own surface root, or null when it is not showing.
244
+ // Painted after the app's and hit-tested before it, which is the whole of
245
+ // what "on top" means on a platform where a view is not a window.
246
+ RnWin32View *logBoxRoot{nullptr};
247
+
248
+ std::string bundlePath;
249
+ // Empty means the bundle is a raw Fabric script rather than a React app.
250
+ std::string moduleName;
251
+ // Metro's entry point, without the extension. Only meaningful in dev mode.
252
+ std::string sourcePath;
253
+ bool surfaceStarted{false};
254
+ // Whether this run is a development one, which is the only thing that decides
255
+ // whether Ctrl+D opens anything. Kept because the ReactInstanceConfig it came
256
+ // from is not.
257
+ bool devMode{false};
258
+ int scaleFactor{1};
259
+ // Whether TrackMouseEvent is armed. Windows sends WM_MOUSELEAVE once and then
260
+ // forgets, so it has to be re-armed on every move; the flag keeps that to one
261
+ // call per entry rather than one per motion message.
262
+ bool trackingMouseLeave{false};
263
+ };
264
+
265
+ Host gHost;
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Logging
269
+ // ---------------------------------------------------------------------------
270
+
271
+ // ReactHost's Logger is where console.log and RN's own logging come out.
272
+ // stderr rather than OutputDebugString: this is a console subsystem binary, and
273
+ // a host whose logs are only visible in a debugger is a host nobody can run
274
+ // from a terminal.
275
+ void logToConsole(const std::string &message, unsigned int logLevel) {
276
+ const char *prefix = "[js]";
277
+ switch (logLevel) {
278
+ case ReactNativeLogLevelFatal:
279
+ case ReactNativeLogLevelError:
280
+ prefix = "[js error]";
281
+ break;
282
+ case ReactNativeLogLevelWarning:
283
+ prefix = "[js warn]";
284
+ break;
285
+ default:
286
+ break;
287
+ }
288
+ std::fprintf(stderr, "%s %s\n", prefix, message.c_str());
289
+ std::fflush(stderr);
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Layout constraints
294
+ // ---------------------------------------------------------------------------
295
+
296
+ // Minimum == maximum pins the root to the window's client box, which is what a
297
+ // full-window surface wants: the root fills, and flex children divide it.
298
+ LayoutConstraints constraintsFor(int width, int height) {
299
+ const facebook::react::Size size{static_cast<Float>(width), static_cast<Float>(height)};
300
+ return LayoutConstraints{
301
+ .minimumSize = size,
302
+ .maximumSize = size,
303
+ .layoutDirection = LayoutDirection::LeftToRight,
304
+ };
305
+ }
306
+
307
+ LayoutContext layoutContextFor(int scaleFactor) {
308
+ return LayoutContext{.pointScaleFactor = static_cast<Float>(scaleFactor)};
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Painting
313
+ // ---------------------------------------------------------------------------
314
+
315
+ // Per window: an ID2D1HwndRenderTarget belongs to one HWND, so each window has
316
+ // its own and a second window cannot borrow the first's.
317
+ bool ensureTarget(HostWindow *made) {
318
+ if (made == nullptr) {
319
+ return false;
320
+ }
321
+ if (made->target) {
322
+ return true;
323
+ }
324
+ if (!gHost.d2dFactory || made->window == nullptr) {
325
+ return false;
326
+ }
327
+ RECT client{};
328
+ GetClientRect(made->window, &client);
329
+ const D2D1_SIZE_U size = D2D1::SizeU(static_cast<UINT32>(client.right - client.left),
330
+ static_cast<UINT32>(client.bottom - client.top));
331
+ if (size.width == 0 || size.height == 0) {
332
+ return false;
333
+ }
334
+ return SUCCEEDED(gHost.d2dFactory->CreateHwndRenderTarget(
335
+ D2D1::RenderTargetProperties(),
336
+ D2D1::HwndRenderTargetProperties(made->window, size),
337
+ &made->target));
338
+ }
339
+
340
+ // Asked for after every mounted transaction. Windows repaints on demand rather
341
+ // than continuously, so without this a mutation changes the view tree and
342
+ // nothing on screen moves until the window happens to be invalidated by
343
+ // something else -- which reads as "mounting is broken" and is not.
344
+ void requestRepaint() {
345
+ // Every window. This asked for the app's own and only the app's own, which
346
+ // was the whole truth when there was only ever one -- and afterwards meant a
347
+ // second window whose content changed and did not redraw until Windows
348
+ // happened to invalidate it for some other reason.
349
+ //
350
+ // All of them rather than the one the transaction was for, because this is
351
+ // called from the mount path with no surface in hand. Invalidating a window
352
+ // that did not change costs one WM_PAINT and is what the main window has
353
+ // always done for a transaction in the error inspector.
354
+ for (const auto &window : gHost.windows) {
355
+ if (window->window != nullptr) {
356
+ InvalidateRect(window->window, nullptr, FALSE);
357
+ }
358
+ }
359
+ }
360
+
361
+ // After every transaction, and after every scroll. Both move views, and a
362
+ // <TextInput>'s peer is a child window that does not move with one: nothing in
363
+ // the view tree owns an HWND, so its placement is recomputed from the tree
364
+ // rather than following it. GTK and AppKit need no equivalent, because there a
365
+ // text field is a widget inside the widget that is the view.
366
+ // Whether the window has to keep repainting because a spinner is turning, and
367
+ // the timer that makes it.
368
+ //
369
+ // An <ActivityIndicator> is painted here rather than mounted -- Windows has no
370
+ // spinner control; see RnWin32View.h -- so nothing invalidates the window
371
+ // between mounts, and without this a spinner is a still picture. GtkSpinner and
372
+ // NSProgressIndicator each drive their own redraw, which is why neither other
373
+ // host needs an equivalent.
374
+ //
375
+ // Started and stopped rather than left running, for the reason
376
+ // Win32AnimationChoreographer.h gives about its own timer: a timer that runs
377
+ // whether or not anything is animating wakes the process sixty times a second
378
+ // forever, which matters more on a laptop than the frame interval does.
379
+ constexpr UINT_PTR kSpinnerTimer = 102;
380
+
381
+ // BASALT_TEST_QUIT_FILE's poll. Repeating, unlike kQuitAfterTimer's one shot:
382
+ // it looks for the file until it finds it. See core/TestQuitFile.h.
383
+ constexpr UINT_PTR kQuitFileTimer = 103;
384
+ bool gSpinnerTimerRunning = false;
385
+
386
+ void updateSpinnerTimer() {
387
+ if (gHost.main().window == nullptr || gHost.main().root == nullptr) {
388
+ return;
389
+ }
390
+ const bool wanted = gHost.main().root->hasAnimatingSpinner() ||
391
+ (gHost.logBoxRoot != nullptr && gHost.logBoxRoot->hasAnimatingSpinner());
392
+ if (wanted == gSpinnerTimerRunning) {
393
+ return;
394
+ }
395
+ gSpinnerTimerRunning = wanted;
396
+ if (wanted) {
397
+ SetTimer(gHost.main().window, kSpinnerTimer, 16, nullptr);
398
+ } else {
399
+ KillTimer(gHost.main().window, kSpinnerTimer);
400
+ }
401
+ }
402
+
403
+ void syncPeersAndRepaint() {
404
+ if (gHost.mountingManager != nullptr) {
405
+ gHost.mountingManager->syncTextInputBounds(gHost.main().root);
406
+ }
407
+ updateSpinnerTimer();
408
+ requestRepaint();
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Calling into JS
413
+ // ---------------------------------------------------------------------------
414
+
415
+ // Runs on the JS thread. `step` lets the script commit a second, different tree
416
+ // so an Update/Remove diff can be seen coming out of Fabric rather than only an
417
+ // initial mount.
418
+ void callRenderFunction(int step) {
419
+ if (gHost.reactHost == nullptr) {
420
+ return;
421
+ }
422
+ gHost.reactHost->runOnRuntimeScheduler([step](facebook::jsi::Runtime &runtime) {
423
+ try {
424
+ auto value = runtime.global().getProperty(runtime, kRenderFunctionName);
425
+ if (!value.isObject() || !value.getObject(runtime).isFunction(runtime)) {
426
+ std::fprintf(stderr, "script defines no global %s()\n", kRenderFunctionName);
427
+ return;
428
+ }
429
+ value.getObject(runtime).getFunction(runtime).call(
430
+ runtime,
431
+ facebook::jsi::Value(static_cast<int>(kSurfaceId)),
432
+ facebook::jsi::Value(step));
433
+ } catch (const facebook::jsi::JSError &error) {
434
+ std::fprintf(stderr,
435
+ "%s() threw: %s\n%s\n",
436
+ kRenderFunctionName,
437
+ error.getMessage().c_str(),
438
+ error.getStack().c_str());
439
+ } catch (const std::exception &error) {
440
+ std::fprintf(stderr, "%s() failed: %s\n", kRenderFunctionName, error.what());
441
+ }
442
+ });
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Startup
447
+ // ---------------------------------------------------------------------------
448
+
449
+ // Feature flags this platform states explicitly. Anything not listed keeps
450
+ // React Native's own default.
451
+ class DesktopFeatureFlags : public facebook::react::ReactNativeFeatureFlagsDefaults {
452
+ public:
453
+ bool enableBridgelessArchitecture() override {
454
+ return true;
455
+ }
456
+ };
457
+
458
+ // TurboModules this platform supplies itself. ReactCxxTurboModuleProvider
459
+ // consults these before its own, so naming a module it also provides replaces
460
+ // it. Every one comes from core/ and is shared with the other two hosts --
461
+ // which is the whole argument of phase 17 arriving as a list of things this
462
+ // file did not have to write.
463
+ facebook::react::TurboModuleProviders makeTurboModuleProviders(
464
+ std::string scriptURL,
465
+ bool devMode,
466
+ std::weak_ptr<basalt::win32::Win32ImageLoader> imageLoader) {
467
+ facebook::react::TurboModuleProviders providers;
468
+ // Whatever the discovered capability packages supply, first: a package naming
469
+ // a module this host also provides is replacing it deliberately, and the same
470
+ // rule already applies to this list against ReactCxxPlatform's own. Answers
471
+ // nullptr for every name when no package supplies one, which is most builds.
472
+ // See core/PackageModules.h.
473
+ providers.emplace_back(basalt::packageTurboModule);
474
+ providers.emplace_back(
475
+ [scriptURL = std::move(scriptURL), devMode, imageLoader = std::move(imageLoader)](
476
+ const std::string &name,
477
+ const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker)
478
+ -> std::shared_ptr<facebook::react::TurboModule> {
479
+ // `Image.getSize` and `Image.prefetch`. Upstream builds this module
480
+ // with no loader at all and offers no way to supply one, so the answer
481
+ // is to build it here with the loader this host already has -- which
482
+ // is also the one holding the decoded images, so a size asked for
483
+ // something on screen costs nothing. See docs/backlog/upstream.md.
484
+ if (name == facebook::react::ImageLoaderModule::kModuleName) {
485
+ return std::make_shared<facebook::react::ImageLoaderModule>(jsInvoker, imageLoader);
486
+ }
487
+ if (name == facebook::react::PlatformConstantsModule::kModuleName) {
488
+ return std::make_shared<basalt::DesktopPlatformConstantsModule>(jsInvoker);
489
+ }
490
+ if (name == basalt::DesktopAppModule::kModuleName) {
491
+ return std::make_shared<basalt::DesktopAppModule>(jsInvoker);
492
+ }
493
+ if (name == basalt::DesktopAppearanceModule::kModuleName) {
494
+ return std::make_shared<basalt::DesktopAppearanceModule>(jsInvoker);
495
+ }
496
+ #ifdef BASALT_HAS_SKIA
497
+ // @shopify/react-native-skia, when the build was pointed at an app that
498
+ // has it. Its `install` is what puts Skia's whole JavaScript API on the
499
+ // global object, and an app that imports Skia does not start without
500
+ // it; see win32/Win32SkiaModule.h.
501
+ if (name == basalt::Win32SkiaModule::kModuleName) {
502
+ return std::make_shared<basalt::Win32SkiaModule>(jsInvoker);
503
+ }
504
+ #endif
505
+ if (name == basalt::DesktopBlobModule::kModuleName) {
506
+ return std::make_shared<basalt::DesktopBlobModule>(jsInvoker);
507
+ }
508
+ if (name == basalt::DesktopFileReaderModule::kModuleName) {
509
+ return std::make_shared<basalt::DesktopFileReaderModule>(jsInvoker);
510
+ }
511
+ if (name == basalt::DesktopClipboardModule::kModuleName) {
512
+ return std::make_shared<basalt::DesktopClipboardModule>(jsInvoker);
513
+ }
514
+ if (name == basalt::DesktopVibrationModule::kModuleName) {
515
+ return std::make_shared<basalt::DesktopVibrationModule>(jsInvoker);
516
+ }
517
+ if (name == basalt::DesktopAlertModule::kModuleName) {
518
+ return std::make_shared<basalt::DesktopAlertModule>(jsInvoker);
519
+ }
520
+ if (name == basalt::DesktopLinkingModule::kModuleName) {
521
+ return std::make_shared<basalt::DesktopLinkingModule>(jsInvoker);
522
+ }
523
+ if (name == basalt::DesktopShareModule::kModuleName) {
524
+ return std::make_shared<basalt::DesktopShareModule>(jsInvoker);
525
+ }
526
+ // The native file dialogs, which React Native has no API for and which
527
+ // are the first thing a desktop app reaches for. See core/DialogModule.h.
528
+ if (name == basalt::DesktopDialogModule::kModuleName) {
529
+ return std::make_shared<basalt::DesktopDialogModule>(jsInvoker);
530
+ }
531
+ // The application menu, which on macOS is also what makes Cmd-C reach
532
+ // a text field. See core/MenuModel.h.
533
+ if (name == basalt::DesktopMenuModule::kModuleName) {
534
+ return std::make_shared<basalt::DesktopMenuModule>(jsInvoker);
535
+ }
536
+ // More than one window, which is more than one surface. See
537
+ // core/WindowHost.h.
538
+ if (name == basalt::DesktopWindowsModule::kModuleName) {
539
+ return std::make_shared<basalt::DesktopWindowsModule>(jsInvoker);
540
+ }
541
+ if (name == basalt::DesktopI18nManagerModule::kModuleName) {
542
+ return std::make_shared<basalt::DesktopI18nManagerModule>(jsInvoker);
543
+ }
544
+ if (name == basalt::DesktopAccessibilityManagerModule::kModuleName) {
545
+ return std::make_shared<basalt::DesktopAccessibilityManagerModule>(jsInvoker);
546
+ }
547
+ if (name == basalt::DesktopAccessibilityInfoModule::kModuleName) {
548
+ return std::make_shared<basalt::DesktopAccessibilityInfoModule>(jsInvoker);
549
+ }
550
+ if (name == basalt::DesktopToastModule::kModuleName) {
551
+ return std::make_shared<basalt::DesktopToastModule>(jsInvoker);
552
+ }
553
+ // react-native-gesture-handler's module. Offered whether or not the app
554
+ // has the library: it costs a name comparison, and the alternative is a
555
+ // host that has to be rebuilt to run an app that uses gestures.
556
+ if (name == basalt::DesktopGestureHandlerModule::kModuleName) {
557
+ return std::make_shared<basalt::DesktopGestureHandlerModule>(jsInvoker);
558
+ }
559
+ #ifdef BASALT_HAS_WORKLETS
560
+ if (name == basalt::DesktopWorkletsModule::kModuleName) {
561
+ return std::make_shared<basalt::DesktopWorkletsModule>(jsInvoker);
562
+ }
563
+ #endif
564
+ #ifdef BASALT_HAS_REANIMATED
565
+ if (name == basalt::DesktopReanimatedModule::kModuleName) {
566
+ return std::make_shared<basalt::DesktopReanimatedModule>(jsInvoker);
567
+ }
568
+ #endif
569
+ // Only outside dev mode. ReactCxxPlatform provides a real DevSettings
570
+ // when a dev server exists, and this provider is consulted first -- so
571
+ // offering ours unconditionally would shadow the one that works.
572
+ if (!devMode && name == basalt::DesktopDevSettingsModule::kModuleName) {
573
+ return std::make_shared<basalt::DesktopDevSettingsModule>(jsInvoker);
574
+ }
575
+ if (name == basalt::DesktopStatusBarModule::kModuleName) {
576
+ return std::make_shared<basalt::DesktopStatusBarModule>(jsInvoker);
577
+ }
578
+ // The window's title bar. This host's own rather than core's, because it
579
+ // needs the window; see win32/Win32WindowModule.h.
580
+ if (name == basalt::Win32WindowModule::kModuleName) {
581
+ return std::make_shared<basalt::Win32WindowModule>(jsInvoker);
582
+ }
583
+ // Only outside dev mode, for the same reason DevSettings is above.
584
+ //
585
+ // This module's scriptURL is what HMRClient registers with Metro as its
586
+ // entry point, and Metro resolves that URL to a *graph*. The URL
587
+ // synthesised here is not the one DevServerHelper actually fetched --
588
+ // that one carries lazy, minify, runModule and app -- so it names a
589
+ // graph nobody built, Metro answers GraphNotFoundError, and Fast
590
+ // Refresh silently never starts. ReactCxxPlatform's own SourceCode
591
+ // module reports the URL the bundle really came from, which is the one
592
+ // whose graph exists.
593
+ if (!devMode && name == basalt::DesktopSourceCodeModule::kModuleName) {
594
+ return std::make_shared<basalt::DesktopSourceCodeModule>(jsInvoker, scriptURL);
595
+ }
596
+ return nullptr;
597
+ });
598
+ return providers;
599
+ }
600
+
601
+ // Runs against the JavaScript runtime before the bundle is evaluated, which is
602
+ // the only moment early enough for what goes in here.
603
+ void installBindings(facebook::jsi::Runtime &runtime) {
604
+ // Before Expo's, and before anything else that might look a module up: this is
605
+ // what makes TurboModuleRegistry's first question answerable on a bridgeless
606
+ // runtime. See core/TurboModuleProxy.h.
607
+ basalt::installTurboModuleProxy(runtime);
608
+ basalt::installExpoRuntime(runtime);
609
+ }
610
+
611
+ std::shared_ptr<const ContextContainer> makeContextContainer() {
612
+ auto contextContainer = std::make_shared<ContextContainer>();
613
+
614
+ // ReactHost throws without these two. Both are real implementations --
615
+ // curl-backed http from core/HttpClient.cpp, and React Native's own
616
+ // boost::beast websocket client -- and neither is about a toolkit, which is
617
+ // why this host gets them for free.
618
+ contextContainer->insert(facebook::react::HttpClientFactoryKey,
619
+ facebook::react::getHttpClientFactory());
620
+ contextContainer->insert(facebook::react::WebSocketClientFactoryKey,
621
+ facebook::react::getWebSocketClientFactory());
622
+
623
+ // MessageQueueThreadFactoryKey is deliberately left unset: ReactHost then
624
+ // installs MessageQueueThreadImpl, a real threaded queue.
625
+ return contextContainer;
626
+ }
627
+
628
+ // BASALT_DUMP_MENU: write the application menu that is actually installed to a
629
+ // file on the way out.
630
+ //
631
+ // Read back from the platform rather than from the model that was sent, which
632
+ // is the point: it says a description became a real menu -- and it is the only
633
+ // way an automated run can see a menu bar at all, since a menu cannot be opened
634
+ // without a person. Written even when it is empty, because "this platform has
635
+ // no menu bar" is exactly what a test on Linux is asserting.
636
+ void dumpMenuIfRequested() {
637
+ const char *path = std::getenv("BASALT_DUMP_MENU");
638
+ if (path == nullptr) {
639
+ return;
640
+ }
641
+ const std::string described = basalt::describeApplicationMenu();
642
+ std::FILE *file = std::fopen(path, "wb");
643
+ if (file == nullptr) {
644
+ std::fprintf(stderr, "could not write the menu dump to %s\n", path);
645
+ return;
646
+ }
647
+ std::fwrite(described.data(), 1, described.size(), file);
648
+ std::fclose(file);
649
+ }
650
+
651
+ // BASALT_DUMP_TREE: write the view tree to a file on the way out, so an
652
+ // automated run can assert on what React actually produced rather than on a
653
+ // screenshot.
654
+ void dumpTreeIfRequested() {
655
+ const char *path = std::getenv("BASALT_DUMP_TREE");
656
+ if (path == nullptr || gHost.main().root == nullptr) {
657
+ return;
658
+ }
659
+ if (FILE *file = std::fopen(path, "wb")) {
660
+ std::string described = gHost.main().root->describeTree();
661
+ // Every other window, each under a header naming its surface. Appended
662
+ // rather than merged for the same reason the inspector is: they are
663
+ // separate trees on screen, and nesting one inside another would say
664
+ // something untrue.
665
+ for (const auto &other : gHost.windows) {
666
+ if (other->surfaceId == kSurfaceId || other->root == nullptr) {
667
+ continue;
668
+ }
669
+ described += "--- window " + std::to_string(static_cast<int>(other->surfaceId)) + " ---\n";
670
+ described += other->root->describeTree();
671
+ }
672
+ // The error inspector is a second surface with a root of its own, so it is
673
+ // invisible to a dump of the app's. Appended rather than merged, because
674
+ // the two are siblings on screen and nesting one inside the other would say
675
+ // something untrue about the tree. Same separator the other two hosts use.
676
+ if (gHost.logBoxRoot != nullptr) {
677
+ described += "--- LogBox ---\n";
678
+ described += gHost.logBoxRoot->describeTree();
679
+ }
680
+ std::fwrite(described.data(), 1, described.size(), file);
681
+ std::fclose(file);
682
+ std::fprintf(stderr, "wrote view tree to %s\n", path);
683
+ } else {
684
+ std::fprintf(stderr, "could not write %s\n", path);
685
+ }
686
+ }
687
+
688
+ // BASALT_SNAPSHOT: render the surface to a PNG on the way out.
689
+ //
690
+ // The tree dump says which mutations arrived; it says nothing about whether any
691
+ // of them reached the screen, and on Windows those are genuinely separate --
692
+ // painting only happens when something invalidates. This is offscreen rather
693
+ // than a screen grab, so it works on a machine with no one watching and needs
694
+ // no window to be visible.
695
+ void snapshotIfRequested() {
696
+ const char *path = std::getenv("BASALT_SNAPSHOT");
697
+ if (path == nullptr || gHost.main().root == nullptr) {
698
+ return;
699
+ }
700
+ if (basalt::win32::writeSnapshot(*gHost.main().root, path)) {
701
+ std::fprintf(stderr, "wrote snapshot to %s\n", path);
702
+ } else {
703
+ std::fprintf(stderr, "could not write %s\n", path);
704
+ }
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // Scripted input
709
+ // ---------------------------------------------------------------------------
710
+
711
+ // BASALT_TEST_TAP and BASALT_TEST_DRAG, the same two escape hatches both other
712
+ // hosts have and for the same reason: the input path is otherwise untestable in
713
+ // automation. Synthesising a real click on Windows means SendInput, which moves
714
+ // the actual cursor and so cannot run beside anything else on the machine --
715
+ // the same objection CGEvent raises on macOS.
716
+ //
717
+ // They enter at the dispatcher rather than at the window procedure, so what
718
+ // they prove is hit testing, emitter lookup and delivery through the event
719
+ // beat, and what they leave unproven is that Windows routes WM_LBUTTONDOWN to
720
+ // `hostProc` at all. A person clicking the window is still the only check on
721
+ // that half, on all three platforms.
722
+ struct ScriptedInput {
723
+ enum class Kind { Tap, Hover, Drag, Wheel, Type, Focus, Close, Quit, Drop, Key };
724
+
725
+ Kind kind{Kind::Tap};
726
+ double fromX{0};
727
+ double fromY{0};
728
+ double toX{0};
729
+ double toY{0};
730
+ // Wheel only: notches, positive being a turn towards the user, which scrolls
731
+ // content down. The sign is the one BASALT_TEST_SCROLL takes on the other
732
+ // hosts, not the one WM_MOUSEWHEEL uses.
733
+ double lines{0};
734
+ // Which window this is about. A tap may name one -- "x,y@3" -- because each
735
+ // window has its own touch dispatcher, and the app's would happily hit-test a
736
+ // tree that is not on screen; a Close names the window to close. Defaults to
737
+ // the app's own, so every spec written before windows existed still means
738
+ // what it did.
739
+ facebook::react::SurfaceId surfaceId{kSurfaceId};
740
+ // Tap only: which button. A secondary tap presses nothing and arrives as a
741
+ // pointer event, which is the whole thing BASALT_TEST_SECONDARY_TAP exists to
742
+ // drive. See core/PointerButtons.h.
743
+ basalt::PointerButton button{basalt::PointerButton::Primary};
744
+ // Type only. Default-initialised explicitly, so that the three kinds that do
745
+ // not carry text can leave it out of a designated initialiser.
746
+ std::string text{};
747
+ // Key only: the combination to press. See core/TestKeys.h.
748
+ basalt::KeyCombination pressed{};
749
+ };
750
+
751
+ // Fired from timers keyed by index, so the vector has to outlive the loop.
752
+ std::vector<ScriptedInput> gScriptedInput;
753
+
754
+ constexpr UINT_PTR kScriptedInputTimerBase = 200;
755
+
756
+ // Every timer id this host uses is distinct.
757
+ //
758
+ // `SetTimer` with an id that is already in use replaces the timer rather than
759
+ // failing, so a collision is silent and its symptom is whatever the timer it
760
+ // replaced was for. That happened: the spinner's repaint timer was given the
761
+ // same id as BASALT_QUIT_AFTER_MS's, and an app with an <ActivityIndicator> in
762
+ // it cancelled its own shutdown and hung. A static_assert cannot check the
763
+ // scripted range against the rest, so the base is held above everything else.
764
+ static_assert(kSecondTreeTimer != kQuitAfterTimer && kSecondTreeTimer != kSpinnerTimer &&
765
+ kSecondTreeTimer != kQuitFileTimer && kQuitAfterTimer != kSpinnerTimer &&
766
+ kQuitAfterTimer != kQuitFileTimer && kSpinnerTimer != kQuitFileTimer,
767
+ "two timers share an id, and SetTimer would silently replace one with the other");
768
+ static_assert(kScriptedInputTimerBase > kSecondTreeTimer &&
769
+ kScriptedInputTimerBase > kQuitAfterTimer &&
770
+ kScriptedInputTimerBase > kSpinnerTimer &&
771
+ kScriptedInputTimerBase > kQuitFileTimer,
772
+ "the scripted-input ids count up from the base and must start above the rest");
773
+
774
+ // The numbers in "10,20,30" -- however many there are, which is what lets one
775
+ // parser read both a tap's pair and a drag's four.
776
+ std::vector<double> parseNumbers(const std::string &spec) {
777
+ std::vector<double> numbers;
778
+ size_t start = 0;
779
+ while (start <= spec.size()) {
780
+ const size_t comma = spec.find(',', start);
781
+ const std::string piece =
782
+ spec.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
783
+ if (!piece.empty()) {
784
+ numbers.push_back(std::strtod(piece.c_str(), nullptr));
785
+ }
786
+ if (comma == std::string::npos) {
787
+ break;
788
+ }
789
+ start = comma + 1;
790
+ }
791
+ return numbers;
792
+ }
793
+
794
+ void CALLBACK fireScriptedInput(HWND hwnd, UINT, UINT_PTR id, DWORD) {
795
+ KillTimer(hwnd, id);
796
+ const size_t index = static_cast<size_t>(id - kScriptedInputTimerBase);
797
+ if (index >= gScriptedInput.size()) {
798
+ return;
799
+ }
800
+ const ScriptedInput &action = gScriptedInput[index];
801
+ switch (action.kind) {
802
+ case ScriptedInput::Kind::Tap: {
803
+ const bool secondary = action.button != basalt::PointerButton::Primary;
804
+ const char *name = secondary ? "BASALT_TEST_SECONDARY_TAP" : "BASALT_TEST_TAP";
805
+ std::fprintf(stderr,
806
+ "%s: tapping (%.0f, %.0f) in window %d\n",
807
+ name,
808
+ action.fromX,
809
+ action.fromY,
810
+ static_cast<int>(action.surfaceId));
811
+ HostWindow *target = gHost.windowFor(action.surfaceId);
812
+ if (target == nullptr) {
813
+ std::fprintf(stderr, "%s: no window %d\n", name, static_cast<int>(action.surfaceId));
814
+ break;
815
+ }
816
+ if (target->touchDispatcher != nullptr) {
817
+ target->touchDispatcher->synthesiseTap(action.fromX, action.fromY, action.button);
818
+ }
819
+ // A real click on a <TextInput> never reaches the touch dispatcher: the
820
+ // peer is a child window, so USER32 routes the click to it and the
821
+ // control focuses itself. That is the one thing a synthesised tap cannot
822
+ // reproduce, so it is done here instead -- and only here, because the
823
+ // real path needs none of it.
824
+ //
825
+ // The primary button only: a right-click on a field does not focus it on
826
+ // any desktop, it asks for a menu.
827
+ if (!secondary && gHost.mountingManager != nullptr &&
828
+ gHost.mountingManager->focusTextInputAt(target->root, action.fromX, action.fromY)) {
829
+ std::fprintf(stderr, "BASALT_TEST_TAP: focused the field there\n");
830
+ }
831
+ break;
832
+ }
833
+
834
+ case ScriptedInput::Kind::Hover:
835
+ std::fprintf(stderr,
836
+ "BASALT_TEST_HOVER: hovering (%.0f, %.0f)\n",
837
+ action.fromX,
838
+ action.fromY);
839
+ if (gHost.main().touchDispatcher != nullptr) {
840
+ gHost.main().touchDispatcher->synthesiseHover(action.fromX, action.fromY);
841
+ }
842
+ break;
843
+
844
+ case ScriptedInput::Kind::Drag:
845
+ std::fprintf(stderr,
846
+ "BASALT_TEST_DRAG: (%.0f, %.0f) -> (%.0f, %.0f)\n",
847
+ action.fromX,
848
+ action.fromY,
849
+ action.toX,
850
+ action.toY);
851
+ gHost.main().touchDispatcher->synthesiseDrag(
852
+ action.fromX, action.fromY, action.toX, action.toY, 20);
853
+ break;
854
+
855
+ case ScriptedInput::Kind::Wheel: {
856
+ // A real WM_MOUSEWHEEL through the real window procedure, unlike the tap
857
+ // and the drag above -- because the two things most likely to be wrong
858
+ // about a wheel on Windows are the ones a dispatcher-level injection
859
+ // would skip. Its coordinates are in screen space and its sign is
860
+ // inverted against a contentOffset, and either mistake produces a wheel
861
+ // that scrolls the wrong thing or the wrong way rather than one that does
862
+ // nothing. So the point is converted back out to screen coordinates here
863
+ // for `hostProc` to convert in again, and the notches are negated for it
864
+ // to negate back.
865
+ //
866
+ // Sent rather than posted: a WM_TIMER callback is already on the UI
867
+ // thread, so this reaches hostProc synchronously and stays ordered with
868
+ // the actions around it.
869
+ POINT screen{static_cast<LONG>(action.fromX), static_cast<LONG>(action.fromY)};
870
+ ClientToScreen(hwnd, &screen);
871
+
872
+ const int delta = static_cast<int>(-action.lines * WHEEL_DELTA);
873
+ std::fprintf(stderr,
874
+ "BASALT_TEST_SCROLL: %.0f lines at (%.0f, %.0f)\n",
875
+ action.lines,
876
+ action.fromX,
877
+ action.fromY);
878
+ SendMessage(hwnd,
879
+ WM_MOUSEWHEEL,
880
+ MAKEWPARAM(0, delta),
881
+ MAKELPARAM(static_cast<WORD>(screen.x), static_cast<WORD>(screen.y)));
882
+ break;
883
+ }
884
+
885
+ case ScriptedInput::Kind::Key: {
886
+ std::fprintf(stderr, "BASALT_TEST_KEY: %s\n", action.pressed.key.c_str());
887
+ if (gHost.main().focusManager != nullptr) {
888
+ gHost.main().focusManager->deliverTestKey(action.pressed);
889
+ }
890
+ break;
891
+ }
892
+
893
+ case ScriptedInput::Kind::Focus: {
894
+ std::fprintf(stderr, "BASALT_TEST_FOCUS: %s\n", action.text.c_str());
895
+ // Not a focus action, and here anyway: this is the instrument for "a key
896
+ // was pressed and nothing on the window has to be focused for it to
897
+ // arrive", which is exactly what Escape closing a <Modal> is.
898
+ if (action.text == "devmenu") {
899
+ // Also not a focus action. Same instrument for the same reason: a key
900
+ // that needs nothing focused to arrive. What it opens is answered by
901
+ // BASALT_TEST_MENU; see core/TestDialog.h.
902
+ basalt::showDevMenu(gHost.reactHost.get());
903
+ break;
904
+ }
905
+ if (action.text == "escape") {
906
+ if (gHost.mountingManager != nullptr) {
907
+ gHost.mountingManager->requestCloseTopModal();
908
+ }
909
+ InvalidateRect(hwnd, nullptr, FALSE);
910
+ break;
911
+ }
912
+ if (gHost.main().focusManager != nullptr) {
913
+ if (action.text == "tab") {
914
+ gHost.main().focusManager->moveFocus(true);
915
+ } else if (action.text == "shift-tab") {
916
+ gHost.main().focusManager->moveFocus(false);
917
+ } else if (action.text == "activate") {
918
+ gHost.main().focusManager->activateFocused();
919
+ } else {
920
+ std::fprintf(stderr, "BASALT_TEST_FOCUS: unknown action\n");
921
+ }
922
+ InvalidateRect(hwnd, nullptr, FALSE);
923
+ }
924
+ break;
925
+ }
926
+
927
+ case ScriptedInput::Kind::Type: {
928
+ // Real WM_CHARs into whichever <TextInput> has focus, so what this skips
929
+ // is the keyboard driver and nothing above it: the EDIT's own handling,
930
+ // EN_CHANGE, the emitter, the event beat, React's re-render and the
931
+ // controlled value coming back down all run exactly as they would.
932
+ //
933
+ // Focus is a precondition rather than something this arranges, which is
934
+ // why a BASALT_TEST_TAP on the field is scheduled before it -- the same
935
+ // pairing both other hosts use.
936
+ const bool typed = gHost.mountingManager != nullptr &&
937
+ gHost.mountingManager->typeIntoFocusedTextInput(action.text);
938
+ std::fprintf(stderr,
939
+ "BASALT_TEST_TYPE: \"%s\"%s\n",
940
+ action.text.c_str(),
941
+ typed ? "" : " -- no field has focus");
942
+ break;
943
+ }
944
+
945
+ case ScriptedInput::Kind::Drop: {
946
+ // A file dropped at a point, reported the way OLE would report it.
947
+ // Entered below the toolkit for the reason the other hosts give: a real
948
+ // drag needs a source outside this process. `fromX`/`fromY` carry the
949
+ // point and `text` the path, reusing the fields the other kinds use.
950
+ basalt::DragPayload payload;
951
+ payload.files.push_back(action.text);
952
+
953
+ const facebook::react::Tag found = basalt::dropTargetAt(
954
+ gHost.windows.empty() ? nullptr : gHost.main().root,
955
+ action.fromX,
956
+ action.fromY,
957
+ basalt::DropAcceptsFiles);
958
+ std::fprintf(stderr,
959
+ "BASALT_TEST_DROP: %s at %g,%g onto tag %d\n",
960
+ action.text.c_str(),
961
+ action.fromX,
962
+ action.fromY,
963
+ static_cast<int>(found));
964
+ if (found == 0) {
965
+ break;
966
+ }
967
+ basalt::DropEvent event;
968
+ event.tag = found;
969
+ event.phase = basalt::DropPhase::Drop;
970
+ event.x = action.fromX;
971
+ event.y = action.fromY;
972
+ event.payload = payload;
973
+ basalt::reportDrop(event);
974
+ break;
975
+ }
976
+
977
+ case ScriptedInput::Kind::Quit: {
978
+ // The session ending, which is what "quit" means here: Windows has no
979
+ // application-level quit gesture either -- Alt+F4 is a window close,
980
+ // which is WM_CLOSE and useCloseRequest's business. So this sends the
981
+ // message a logout sends, and the handler for it is the whole of this
982
+ // host's half.
983
+ std::fprintf(stderr, "BASALT_TEST_QUIT: ending the session\n");
984
+ HWND main = gHost.windows.empty() ? nullptr : gHost.main().window;
985
+ if (main != nullptr) {
986
+ SendMessage(main, WM_QUERYENDSESSION, 0, ENDSESSION_CLOSEAPP);
987
+ }
988
+ break;
989
+ }
990
+
991
+ case ScriptedInput::Kind::Close: {
992
+ // The window manager's close, not the app's: WM_CLOSE is what the caption
993
+ // button sends, so the host's own handling runs exactly as it would for a
994
+ // person. Which is the whole point -- an app closing a window and a
995
+ // person closing one take different paths, and only one of them can leave
996
+ // the host holding a record whose HWND is gone.
997
+ HostWindow *closing = gHost.windowFor(action.surfaceId);
998
+ std::fprintf(stderr,
999
+ "BASALT_TEST_CLOSE_WINDOW: %d%s\n",
1000
+ static_cast<int>(action.surfaceId),
1001
+ closing != nullptr ? "" : " -- no such window");
1002
+ if (closing != nullptr && closing->window != nullptr) {
1003
+ PostMessage(closing->window, WM_CLOSE, 0, 0);
1004
+ }
1005
+ break;
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ // Queues one action a second, starting a second and a half in -- late enough
1011
+ // that the first tree has been committed and mounted. Returns the delay after
1012
+ // the last one, so a drag can be scheduled behind the taps.
1013
+ UINT scheduleScriptedInput(const ScriptedInput &action, UINT delayMs) {
1014
+ gScriptedInput.push_back(action);
1015
+ SetTimer(gHost.main().window,
1016
+ kScriptedInputTimerBase + gScriptedInput.size() - 1,
1017
+ delayMs,
1018
+ fireScriptedInput);
1019
+ return delayMs + 1000;
1020
+ }
1021
+
1022
+ // "x,y" pairs separated by ';' -- the spelling BASALT_TEST_TAP and
1023
+ // BASALT_TEST_HOVER share, since a press and a hover are both just a point.
1024
+ UINT scheduleTestPoints(const char *spec,
1025
+ UINT delayMs,
1026
+ ScriptedInput::Kind kind,
1027
+ basalt::PointerButton button = basalt::PointerButton::Primary) {
1028
+ const std::string all(spec);
1029
+ size_t start = 0;
1030
+ while (start <= all.size()) {
1031
+ const size_t semicolon = all.find(';', start);
1032
+ const std::string point =
1033
+ all.substr(start, semicolon == std::string::npos ? std::string::npos : semicolon - start);
1034
+ // "x,y@3" names the window; without an `@` it is the app's own.
1035
+ facebook::react::SurfaceId surfaceId = kSurfaceId;
1036
+ std::string coordinates = point;
1037
+ if (const size_t at = point.find('@'); at != std::string::npos) {
1038
+ surfaceId = static_cast<facebook::react::SurfaceId>(std::atoi(point.c_str() + at + 1));
1039
+ coordinates = point.substr(0, at);
1040
+ }
1041
+ const std::vector<double> numbers = parseNumbers(coordinates);
1042
+ if (numbers.size() == 2) {
1043
+ delayMs = scheduleScriptedInput(ScriptedInput{.kind = kind,
1044
+ .fromX = numbers[0],
1045
+ .fromY = numbers[1],
1046
+ .surfaceId = surfaceId,
1047
+ .button = button},
1048
+ delayMs);
1049
+ }
1050
+ if (semicolon == std::string::npos) {
1051
+ break;
1052
+ }
1053
+ start = semicolon + 1;
1054
+ }
1055
+ return delayMs;
1056
+ }
1057
+
1058
+ // BASALT_TEST_DRAG: "x1,y1,x2,y2" -- one press, twenty moves and a release, for
1059
+ // the gestures a tap cannot reach. A pan is defined by the movement between
1060
+ // press and release, so a tap can never exercise one.
1061
+ UINT scheduleTestDrag(const char *spec, UINT delayMs) {
1062
+ const std::vector<double> numbers = parseNumbers(std::string(spec));
1063
+ if (numbers.size() != 4) {
1064
+ return delayMs;
1065
+ }
1066
+ return scheduleScriptedInput(ScriptedInput{.kind = ScriptedInput::Kind::Drag,
1067
+ .fromX = numbers[0],
1068
+ .fromY = numbers[1],
1069
+ .toX = numbers[2],
1070
+ .toY = numbers[3]},
1071
+ delayMs);
1072
+ }
1073
+
1074
+ // BASALT_TEST_SCROLL: "x,y,lines" triples separated by ';'. Positive lines
1075
+ // scroll down, as a contentOffset does -- the same spelling the macOS host
1076
+ // takes, so one variable drives a comparison across both.
1077
+ UINT scheduleTestScrolls(const char *spec, UINT delayMs) {
1078
+ const std::string all(spec);
1079
+ size_t start = 0;
1080
+ while (start <= all.size()) {
1081
+ const size_t semicolon = all.find(';', start);
1082
+ const std::string step =
1083
+ all.substr(start, semicolon == std::string::npos ? std::string::npos : semicolon - start);
1084
+ const std::vector<double> numbers = parseNumbers(step);
1085
+ if (numbers.size() == 3) {
1086
+ delayMs = scheduleScriptedInput(ScriptedInput{.kind = ScriptedInput::Kind::Wheel,
1087
+ .fromX = numbers[0],
1088
+ .fromY = numbers[1],
1089
+ .lines = numbers[2]},
1090
+ delayMs);
1091
+ }
1092
+ if (semicolon == std::string::npos) {
1093
+ break;
1094
+ }
1095
+ start = semicolon + 1;
1096
+ }
1097
+ return delayMs;
1098
+ }
1099
+
1100
+ // The two escape hatches, run once, before anything is torn down.
1101
+ //
1102
+ // "Before anything" has to include the *window*, which is why this is not
1103
+ // simply the first thing `shutdown` does. Destroying the host window destroys
1104
+ // every child window with it, and a <TextInput>'s peer is one -- so a tree
1105
+ // dumped after that reports every field as empty and unfocused, which reads as
1106
+ // a broken text field and is a dead HWND being asked a question. WM_CLOSE is
1107
+ // where nothing has been destroyed yet.
1108
+ //
1109
+ // Idempotent, because both callers are real: a window closed by the user or by
1110
+ // BASALT_QUIT_AFTER_MS arrives through WM_CLOSE, and a run that fails before
1111
+ // the window exists only reaches `shutdown`.
1112
+ void captureBeforeTeardown() {
1113
+ static bool captured = false;
1114
+ if (captured) {
1115
+ return;
1116
+ }
1117
+ captured = true;
1118
+ dumpTreeIfRequested();
1119
+ dumpMenuIfRequested();
1120
+ snapshotIfRequested();
1121
+ }
1122
+
1123
+ void shutdown() {
1124
+ // Before the surface stops, which tears the tree down -- and before the
1125
+ // window goes, which takes every text field's peer with it.
1126
+ captureBeforeTeardown();
1127
+
1128
+ if (gHost.choreographer != nullptr) {
1129
+ gHost.choreographer->detach();
1130
+ }
1131
+ // Before the host goes: work posted to a host on its way out must not run.
1132
+ basalt::setRuntimeRunner(nullptr);
1133
+ if (gHost.reactHost != nullptr) {
1134
+ // Surfaces must stop before the host goes away, or teardown asserts.
1135
+ gHost.reactHost->stopAllSurfaces();
1136
+ gHost.surfaceStarted = false;
1137
+ // Destroying the host joins the JS thread, so it has to happen while the
1138
+ // mounting manager it holds is still alive.
1139
+ gHost.reactHost.reset();
1140
+ }
1141
+ gHost.choreographer.reset();
1142
+ gHost.runLoopObserverManager.reset();
1143
+
1144
+ // Every window, not only the app's own -- which is what this said when there
1145
+ // was only ever one, and did not say when there stopped being.
1146
+ //
1147
+ // It matters most for the Direct2D target. A record left for `gHost`'s own
1148
+ // destructor is released at static destruction, which is after `main` has
1149
+ // returned and after `CoUninitialize`, and releasing a COM interface once COM
1150
+ // has been torn down is undefined rather than merely late. A second window
1151
+ // still open at quit is exactly when that happens.
1152
+ //
1153
+ // The dispatchers go before the manager they hold a raw pointer into. Nothing
1154
+ // can reach them by now -- the message loop has already returned -- but the
1155
+ // order is the part that stays true if that ever stops being so.
1156
+ for (const auto &window : gHost.windows) {
1157
+ window->focusManager.reset();
1158
+ window->touchDispatcher.reset();
1159
+ window->target.Reset();
1160
+ }
1161
+ // And the roots, which the manager made and which name nothing after it goes.
1162
+ gHost.windows.clear();
1163
+ gHost.mountingManager.reset();
1164
+ }
1165
+
1166
+ // ---------------------------------------------------------------------------
1167
+ // The window
1168
+ // ---------------------------------------------------------------------------
1169
+
1170
+ // Starts or stops React Native's error inspector.
1171
+ //
1172
+ // It is `LogBoxInspectorContainer`, registered by AppRegistry under the name
1173
+ // "LogBox" exactly as an app registers its own component -- so this is a second
1174
+ // surface rather than an overlay this host draws, and everything in it is React
1175
+ // Native's own JavaScript. See core/LogBoxSurface.h.
1176
+ //
1177
+ // Input is retargeted rather than layered. On the other two desktops a view is
1178
+ // a widget and the toolkit routes a press to whatever is on top; here there is
1179
+ // nothing to route, so the dispatchers are pointed at the inspector's root
1180
+ // while it is up and back at the app's when it comes down.
1181
+ void hideLogBoxSurface() {
1182
+ if (gHost.reactHost == nullptr || gHost.logBoxRoot == nullptr) {
1183
+ return;
1184
+ }
1185
+ gHost.reactHost->stopSurface(kLogBoxSurfaceId);
1186
+ gHost.mountingManager->destroySurfaceRoot(kLogBoxSurfaceId);
1187
+ gHost.logBoxRoot = nullptr;
1188
+ if (gHost.main().touchDispatcher != nullptr) {
1189
+ gHost.main().touchDispatcher->setSurfaceRoot(gHost.main().root);
1190
+ }
1191
+ if (gHost.main().focusManager != nullptr) {
1192
+ gHost.main().focusManager->setSurfaceRoot(gHost.main().root);
1193
+ }
1194
+ if (gHost.main().window != nullptr) {
1195
+ InvalidateRect(gHost.main().window, nullptr, FALSE);
1196
+ }
1197
+ }
1198
+
1199
+ void showLogBoxSurface(const std::string &appKey) {
1200
+ if (gHost.reactHost == nullptr || gHost.main().root == nullptr || gHost.logBoxRoot != nullptr) {
1201
+ return;
1202
+ }
1203
+
1204
+ RECT client{};
1205
+ GetClientRect(gHost.main().window, &client);
1206
+ const int width = client.right - client.left;
1207
+ const int height = client.bottom - client.top;
1208
+ if (width <= 0 || height <= 0) {
1209
+ return;
1210
+ }
1211
+
1212
+ gHost.logBoxRoot = gHost.mountingManager->createSurfaceRoot(kLogBoxSurfaceId);
1213
+ gHost.logBoxRoot->setFrame(0, 0, static_cast<float>(width), static_cast<float>(height));
1214
+ if (gHost.main().touchDispatcher != nullptr) {
1215
+ gHost.main().touchDispatcher->setSurfaceRoot(gHost.logBoxRoot);
1216
+ }
1217
+ if (gHost.main().focusManager != nullptr) {
1218
+ gHost.main().focusManager->setSurfaceRoot(gHost.logBoxRoot);
1219
+ }
1220
+
1221
+ gHost.reactHost->startSurface(kLogBoxSurfaceId,
1222
+ appKey,
1223
+ folly::dynamic::object(),
1224
+ constraintsFor(width, height),
1225
+ layoutContextFor(gHost.scaleFactor));
1226
+ InvalidateRect(gHost.main().window, nullptr, FALSE);
1227
+ }
1228
+
1229
+ LRESULT CALLBACK hostProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
1230
+ // Which window this is for. One procedure serves every window this host
1231
+ // makes, so a message that reached for `the` window would paint, hit-test and
1232
+ // resize the app's own however many are open -- which is exactly the bug
1233
+ // multiple windows would otherwise introduce, and it would look like a second
1234
+ // window that renders nothing and a first one that flickers.
1235
+ //
1236
+ // Null before the main window is in the list, which is every message
1237
+ // CreateWindowEx sends before it returns. Those are the ones Windows sends to
1238
+ // set a window up, and DefWindowProc is the right answer to all of them.
1239
+ HostWindow *self = gHost.windowFor(hwnd);
1240
+
1241
+ switch (message) {
1242
+ // The title bar. Each of these is answered only while an app has asked for
1243
+ // a hidden one, and falls through to Windows otherwise; see
1244
+ // win32/Win32TitleBar.h.
1245
+ case WM_NCCALCSIZE: {
1246
+ LRESULT result = 0;
1247
+ if (basalt::titleBar().handleNcCalcSize(wparam, lparam, result)) {
1248
+ return result;
1249
+ }
1250
+ break;
1251
+ }
1252
+
1253
+ case WM_NCHITTEST: {
1254
+ LRESULT result = 0;
1255
+ if (basalt::titleBar().handleNcHitTest(lparam, gHost.main().root, result)) {
1256
+ return result;
1257
+ }
1258
+ break;
1259
+ }
1260
+
1261
+ case WM_NCMOUSEMOVE:
1262
+ case WM_NCMOUSELEAVE:
1263
+ case WM_NCLBUTTONDOWN:
1264
+ case WM_NCLBUTTONDBLCLK:
1265
+ case WM_NCLBUTTONUP: {
1266
+ LRESULT result = 0;
1267
+ if (basalt::titleBar().handleNcMouse(message, wparam, result)) {
1268
+ return result;
1269
+ }
1270
+ break;
1271
+ }
1272
+
1273
+ // An inactive window's caption buttons are drawn dimmer, as Windows' own are.
1274
+ case WM_ACTIVATE:
1275
+ basalt::titleBar().handleActivate(LOWORD(wparam) != WA_INACTIVE);
1276
+ break;
1277
+
1278
+ // Light or dark, changed in Settings while the app runs. Windows announces
1279
+ // it with this message and this string, and nothing on this host listened
1280
+ // for it -- so neither the title bar nor Appearance ever changed.
1281
+ case WM_SETTINGCHANGE:
1282
+ if (lparam != 0 &&
1283
+ std::wcscmp(reinterpret_cast<const wchar_t *>(lparam), L"ImmersiveColorSet") == 0) {
1284
+ basalt::notifyColorSchemeChanged();
1285
+ }
1286
+ break;
1287
+
1288
+ // A move changes the bounds `useWindow()` reports and nothing else, so
1289
+ // unlike WM_SIZE there is no surface to reconstrain -- which is exactly why
1290
+ // it needs its own case: WM_SIZE would never arrive, and `center()` would
1291
+ // look like it had done nothing.
1292
+ case WM_MOVE:
1293
+ basalt::notifyWindowBoundsChanged();
1294
+ return 0;
1295
+
1296
+ case WM_SIZE: {
1297
+ if (self == nullptr) {
1298
+ break;
1299
+ }
1300
+ const int width = LOWORD(lparam);
1301
+ const int height = HIWORD(lparam);
1302
+ if (self->target) {
1303
+ self->target->Resize(D2D1::SizeU(static_cast<UINT32>(width), static_cast<UINT32>(height)));
1304
+ }
1305
+ // The surface has to be told, or the root keeps the size it started with
1306
+ // while the window changes around it. This window's surface: constraining
1307
+ // the app's from every window would lay the first window's tree out to the
1308
+ // second window's size.
1309
+ if (gHost.surfaceStarted && width > 0 && height > 0) {
1310
+ self->root->setFrame(0, 0, static_cast<float>(width), static_cast<float>(height));
1311
+ gHost.reactHost->setSurfaceConstraints(
1312
+ self->surfaceId, constraintsFor(width, height), layoutContextFor(gHost.scaleFactor));
1313
+ // A <Modal> is sized from its own shadow-node state rather than from a
1314
+ // style, and React Native's C++ platform answers "what size is the
1315
+ // screen" with zero. Told here, where the window's size is already
1316
+ // being handed to Fabric.
1317
+ gHost.mountingManager->setSurfaceSize(static_cast<float>(width), static_cast<float>(height));
1318
+ // The window moved or was resized, which `useWindow()` hands an app as live
1319
+ // bounds. Here rather than in a watcher of its own: this is already the one
1320
+ // place that learns it.
1321
+ basalt::notifyWindowBoundsChanged();
1322
+ // The inspector covers the main window, so it resizes with it -- and
1323
+ // only with it: an error is about the app rather than about a window.
1324
+ if (gHost.logBoxRoot != nullptr && self->surfaceId == kSurfaceId) {
1325
+ gHost.logBoxRoot->setFrame(0, 0, static_cast<float>(width), static_cast<float>(height));
1326
+ gHost.reactHost->setSurfaceConstraints(
1327
+ kLogBoxSurfaceId, constraintsFor(width, height), layoutContextFor(gHost.scaleFactor));
1328
+ }
1329
+ }
1330
+ // A maximise moves a hidden title bar's caption, and nothing else says so.
1331
+ basalt::titleBar().refreshMetrics();
1332
+ return 0;
1333
+ }
1334
+
1335
+ case WM_DPICHANGED: {
1336
+ // Yoga rounds to physical pixels, so telling it the wrong factor puts
1337
+ // half-pixel seams between adjacent views. The high word is the new DPI;
1338
+ // 96 is one.
1339
+ gHost.scaleFactor = static_cast<int>(HIWORD(wparam) / 96);
1340
+ if (gHost.scaleFactor < 1) {
1341
+ gHost.scaleFactor = 1;
1342
+ }
1343
+ // Windows also hands back the rectangle the window should move to, and
1344
+ // ignoring it leaves the window the wrong size on the new monitor.
1345
+ if (const auto *suggested = reinterpret_cast<const RECT *>(lparam)) {
1346
+ SetWindowPos(hwnd,
1347
+ nullptr,
1348
+ suggested->left,
1349
+ suggested->top,
1350
+ suggested->right - suggested->left,
1351
+ suggested->bottom - suggested->top,
1352
+ SWP_NOZORDER | SWP_NOACTIVATE);
1353
+ }
1354
+ // The caption is sized in device-independent pixels.
1355
+ basalt::titleBar().refreshMetrics();
1356
+ return 0;
1357
+ }
1358
+
1359
+ // Mouse coordinates arrive in client-area pixels, which are the surface
1360
+ // root's own coordinates: WM_SIZE sizes the root to the client rectangle,
1361
+ // so the two spaces are the same one and nothing has to be converted.
1362
+ // Each button is its own message on Windows, which is why there are six of
1363
+ // these rather than one with a parameter. Only the primary one captures or
1364
+ // presses anything; see core/PointerButtons.h.
1365
+ case WM_LBUTTONDOWN:
1366
+ case WM_RBUTTONDOWN:
1367
+ case WM_MBUTTONDOWN: {
1368
+ if (self == nullptr || self->touchDispatcher == nullptr) {
1369
+ break;
1370
+ }
1371
+ // A new press: the drag question is open again.
1372
+ self->dragAsked = false;
1373
+ const basalt::PointerButton button = message == WM_LBUTTONDOWN
1374
+ ? basalt::PointerButton::Primary
1375
+ : (message == WM_RBUTTONDOWN ? basalt::PointerButton::Secondary
1376
+ : basalt::PointerButton::Middle);
1377
+ // Capture, or a drag that leaves the window stops being reported and the
1378
+ // release never arrives -- which leaves the responder system believing a
1379
+ // finger is still down and swallows every press after it. AppKit and GTK
1380
+ // route a drag back to the view that took the press for free; here it has
1381
+ // to be asked for.
1382
+ //
1383
+ // Only for the button that can drag. A secondary click produces a pointer
1384
+ // event and is over; capturing the mouse for it would take the pointer
1385
+ // away from everything else for no reason.
1386
+ if (basalt::isPressButton(button)) {
1387
+ SetCapture(hwnd);
1388
+ }
1389
+ self->touchDispatcher->dispatchTouchStart(
1390
+ GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam), button);
1391
+ return 0;
1392
+ }
1393
+
1394
+ case WM_MOUSEMOVE:
1395
+ basalt::titleBar().clearHover();
1396
+ if (self != nullptr && self->touchDispatcher != nullptr) {
1397
+ // The touch model has no place for motion with no button down, and the
1398
+ // dispatcher drops it; the check here is only to keep an idle mouse
1399
+ // from walking the view tree sixty times a second.
1400
+ if (self->touchDispatcher->isDown()) {
1401
+ // A drag out of the window, if the press began on a marked view.
1402
+ // Asked once per press and here rather than on the button going
1403
+ // down: a press is not a drag until it moves.
1404
+ //
1405
+ // DoDragDrop runs its own modal loop until the drop, so the touch
1406
+ // that started this never ends -- it is cancelled rather than left
1407
+ // hanging, the same arrangement the AppKit host uses.
1408
+ if (!self->dragAsked) {
1409
+ self->dragAsked = true;
1410
+ if (basalt::beginDragIfMarked(self->root, GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam))) {
1411
+ self->touchDispatcher->dispatchTouchCancel();
1412
+ return 0;
1413
+ }
1414
+ }
1415
+ self->touchDispatcher->dispatchTouchMove(GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
1416
+ } else {
1417
+ // Hover, which is a different question about the same message. Views
1418
+ // that listen for none of it cost a hit test and nothing more; see
1419
+ // core/HoverTracker.h.
1420
+ self->touchDispatcher->dispatchHover(GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
1421
+ }
1422
+ }
1423
+ // Without this the cursor leaving the window is silent, and whatever it
1424
+ // was over stays hovered for good. GTK's motion controller and AppKit's
1425
+ // tracking area both report an exit on their own; Windows has to be asked,
1426
+ // once per entry, and disarms itself when it fires.
1427
+ if (!gHost.trackingMouseLeave) {
1428
+ TRACKMOUSEEVENT tracking{};
1429
+ tracking.cbSize = sizeof(tracking);
1430
+ tracking.dwFlags = TME_LEAVE;
1431
+ tracking.hwndTrack = hwnd;
1432
+ gHost.trackingMouseLeave = TrackMouseEvent(&tracking) != FALSE;
1433
+ }
1434
+ return 0;
1435
+
1436
+ // Tab, Enter and space. A <TextInput>'s peer is a real child window and
1437
+ // takes its keys directly, so anything reaching here is meant for the
1438
+ // painted views -- which have no window and so no focus of Windows' own.
1439
+ case WM_KEYDOWN:
1440
+ // Ctrl+D opens React Native's developer menu, which is the shortcut it
1441
+ // uses on a simulator and the closest thing a desktop has to a phone's
1442
+ // shake. Nothing in the window need have focus for it, which is why it is
1443
+ // answered here rather than by the focus manager.
1444
+ if (wparam == 'D' && (GetKeyState(VK_CONTROL) & 0x8000) != 0 && gHost.devMode &&
1445
+ gHost.reactHost != nullptr) {
1446
+ basalt::showDevMenu(gHost.reactHost.get());
1447
+ return 0;
1448
+ }
1449
+ // An app's own declared shortcuts, before Escape and before Tab: an app
1450
+ // that binds Escape means it. After Ctrl+D above, deliberately -- the
1451
+ // developer menu is not an app's to take, and an app that bound Ctrl+D
1452
+ // would otherwise make its own host undebuggable.
1453
+ if (gHost.main().focusManager != nullptr &&
1454
+ gHost.main().focusManager->handleDeclaredKey(static_cast<unsigned int>(wparam))) {
1455
+ return 0;
1456
+ }
1457
+ // Escape closes the topmost <Modal> -- or rather, asks the app to. React
1458
+ // Native's `onRequestClose` is the hardware back button on Android and
1459
+ // the swipe-down on iOS; on a desktop it is Escape, and a modal the app
1460
+ // does not close in response stays up, which is deliberate.
1461
+ if (wparam == VK_ESCAPE && gHost.mountingManager != nullptr &&
1462
+ gHost.mountingManager->requestCloseTopModal()) {
1463
+ return 0;
1464
+ }
1465
+ if (gHost.main().focusManager != nullptr &&
1466
+ gHost.main().focusManager->handleKeyDown(static_cast<unsigned int>(wparam))) {
1467
+ InvalidateRect(hwnd, nullptr, FALSE);
1468
+ return 0;
1469
+ }
1470
+ break;
1471
+
1472
+ case WM_MOUSELEAVE:
1473
+ gHost.trackingMouseLeave = false;
1474
+ basalt::titleBar().clearHover();
1475
+ if (gHost.main().touchDispatcher != nullptr) {
1476
+ gHost.main().touchDispatcher->dispatchHoverLeave();
1477
+ }
1478
+ return 0;
1479
+
1480
+ case WM_LBUTTONUP:
1481
+ case WM_RBUTTONUP:
1482
+ case WM_MBUTTONUP: {
1483
+ // `self`, not `gHost.main()`. This reached for the app's own window even
1484
+ // when the message was for another one, so a release in a second window
1485
+ // ended a touch in the first -- and left the second believing a finger
1486
+ // was still down, which swallows every press after it.
1487
+ if (self == nullptr || self->touchDispatcher == nullptr) {
1488
+ break;
1489
+ }
1490
+ const basalt::PointerButton button = message == WM_LBUTTONUP
1491
+ ? basalt::PointerButton::Primary
1492
+ : (message == WM_RBUTTONUP ? basalt::PointerButton::Secondary
1493
+ : basalt::PointerButton::Middle);
1494
+ // The end first: ReleaseCapture sends WM_CAPTURECHANGED synchronously,
1495
+ // and that is a cancel. Releasing first would turn every ordinary click
1496
+ // into a cancelled touch, which is a press that never fires.
1497
+ self->touchDispatcher->dispatchTouchEnd(
1498
+ GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam), button);
1499
+ if (basalt::isPressButton(button) && GetCapture() == hwnd) {
1500
+ ReleaseCapture();
1501
+ }
1502
+ return 0;
1503
+ }
1504
+
1505
+ // The wheel. Unlike every other mouse message, its coordinates are in
1506
+ // *screen* space -- the message is sent to the focused window rather than
1507
+ // to the one under the pointer, so a client-relative position would be
1508
+ // meaningless. Forgetting ScreenToClient gives a wheel that scrolls the
1509
+ // wrong list, or nothing, depending on where the window is on the desktop.
1510
+ case WM_MOUSEWHEEL:
1511
+ case WM_MOUSEHWHEEL: {
1512
+ if (gHost.mountingManager == nullptr || gHost.main().root == nullptr) {
1513
+ break;
1514
+ }
1515
+ POINT point{GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
1516
+ ScreenToClient(hwnd, &point);
1517
+
1518
+ const double notches =
1519
+ static_cast<double>(GET_WHEEL_DELTA_WPARAM(wparam)) / static_cast<double>(WHEEL_DELTA);
1520
+ const double step = notches * basalt::Win32ScrollViewManager::kWheelStepPixels;
1521
+
1522
+ // Both axes are inverted against React Native's, and for different
1523
+ // reasons. A positive WM_MOUSEWHEEL delta is a turn away from the user,
1524
+ // which moves content down and so *reduces* contentOffset; a positive
1525
+ // WM_MOUSEHWHEEL delta is a tilt to the right, which is the direction
1526
+ // contentOffset already grows in. Getting either wrong gives a list that
1527
+ // scrolls backwards, which is the kind of bug nobody reports as a bug.
1528
+ const double dx = message == WM_MOUSEHWHEEL ? step : 0.0;
1529
+ const double dy = message == WM_MOUSEWHEEL ? -step : 0.0;
1530
+
1531
+ if (gHost.mountingManager->scrollAt(gHost.main().root, point.x, point.y, dx, dy)) {
1532
+ // Nothing else asks for this: a scroll changes no view's frame, so no
1533
+ // transaction is mounted and setOnDidMount never fires.
1534
+ syncPeersAndRepaint();
1535
+ return 0;
1536
+ }
1537
+ break;
1538
+ }
1539
+
1540
+ // A <TextInput>'s peer is a real EDIT control, and an EDIT reports to its
1541
+ // parent rather than to itself. EN_CHANGE, EN_SETFOCUS and EN_KILLFOCUS all
1542
+ // arrive here, which is why the mounting manager has a method for them at
1543
+ // all: on GTK and AppKit the widget is the view and the toolkit routes this
1544
+ // without a host in the middle.
1545
+ case WM_COMMAND:
1546
+ // The application menu, first: its items arrive here with a null lParam,
1547
+ // which is how a menu command is told from a control's notification. See
1548
+ // win32/Win32MenuBar.h.
1549
+ if (lparam == 0 && HIWORD(wparam) == 0 &&
1550
+ basalt::win32::handleMenuCommand(static_cast<unsigned int>(LOWORD(wparam)))) {
1551
+ return 0;
1552
+ }
1553
+ // A field taking focus is the other half of the focus rule: the peer is a
1554
+ // real window and holds real Win32 focus, so whichever painted view was
1555
+ // wearing the ring has to give it up. Only one of the two kinds of focus
1556
+ // can be true at a time.
1557
+ if (HIWORD(wparam) == EN_SETFOCUS && gHost.main().focusManager != nullptr) {
1558
+ gHost.main().focusManager->textInputTookFocus();
1559
+ InvalidateRect(hwnd, nullptr, FALSE);
1560
+ }
1561
+ if (gHost.mountingManager != nullptr &&
1562
+ gHost.mountingManager->handleControlCommand(wparam, lparam)) {
1563
+ return 0;
1564
+ }
1565
+ break;
1566
+
1567
+ // And an EDIT asks its parent what colours to use. Without an answer it
1568
+ // draws in the system's, which have nothing to do with the `style` the
1569
+ // component was given -- on a dark field that is dark text on dark.
1570
+ // WM_CTLCOLORSTATIC as well, because a read-only EDIT asks with that one.
1571
+ case WM_CTLCOLOREDIT:
1572
+ case WM_CTLCOLORSTATIC:
1573
+ if (gHost.mountingManager != nullptr) {
1574
+ if (HBRUSH brush = gHost.mountingManager->controlColor(
1575
+ reinterpret_cast<HDC>(wparam), reinterpret_cast<HWND>(lparam))) {
1576
+ return reinterpret_cast<LRESULT>(brush);
1577
+ }
1578
+ }
1579
+ break;
1580
+
1581
+ case WM_CAPTURECHANGED:
1582
+ // Capture taken away by something else -- a modal dialog, Alt+Tab, the
1583
+ // debugger. The release will never arrive, so the touch has to be
1584
+ // cancelled here or the responder system waits for a finger that is gone.
1585
+ if (gHost.main().touchDispatcher != nullptr) {
1586
+ gHost.main().touchDispatcher->dispatchTouchCancel();
1587
+ }
1588
+ return 0;
1589
+
1590
+ case WM_TIMER:
1591
+ if (wparam == kSpinnerTimer) {
1592
+ InvalidateRect(hwnd, nullptr, FALSE);
1593
+ return 0;
1594
+ }
1595
+ if (wparam == kSecondTreeTimer) {
1596
+ KillTimer(hwnd, kSecondTreeTimer);
1597
+ std::fprintf(stderr, "--- committing tree 2 from JS ---\n");
1598
+ callRenderFunction(2);
1599
+ }
1600
+ return 0;
1601
+
1602
+ case WM_PAINT: {
1603
+ PAINTSTRUCT paint{};
1604
+ BeginPaint(hwnd, &paint);
1605
+ // Into this window's own target: an ID2D1HwndRenderTarget belongs to one
1606
+ // HWND, and painting a second window through the first one's would draw
1607
+ // into the first.
1608
+ if (self != nullptr && ensureTarget(self) && self->root != nullptr) {
1609
+ self->target->BeginDraw();
1610
+ self->target->Clear(D2D1::ColorF(D2D1::ColorF::Black));
1611
+ self->target->SetTransform(D2D1::Matrix3x2F::Identity());
1612
+ self->root->paint(self->target.Get());
1613
+ // The error inspector, over the app, in the main window. A second
1614
+ // surface rather than anything this host draws; see
1615
+ // core/LogBoxSurface.h.
1616
+ if (gHost.logBoxRoot != nullptr && self->surfaceId == kSurfaceId) {
1617
+ self->target->SetTransform(D2D1::Matrix3x2F::Identity());
1618
+ gHost.logBoxRoot->paint(self->target.Get());
1619
+ }
1620
+ // A hidden title bar's caption buttons, over the app's own content.
1621
+ // The main window's: the title bar is a process-wide seam.
1622
+ if (self->surfaceId == kSurfaceId) {
1623
+ basalt::titleBar().paintButtons(self->target.Get());
1624
+ }
1625
+ // A lost device is reported here and nowhere else. Dropping the target
1626
+ // is the whole recovery: the next WM_PAINT rebuilds it.
1627
+ if (self->target->EndDraw() == D2DERR_RECREATE_TARGET) {
1628
+ self->target.Reset();
1629
+ }
1630
+ }
1631
+ EndPaint(hwnd, &paint);
1632
+ return 0;
1633
+ }
1634
+
1635
+ // How big this window may be. Windows asks rather than being told, which is
1636
+ // why the limits live in core and are read here: there is nowhere to have
1637
+ // passed them in. See core/WindowControl.h.
1638
+ //
1639
+ // The numbers are the *frame*, and an app's are its content -- so each is
1640
+ // adjusted outwards by whatever the border and caption cost, the same
1641
+ // correction `setWindowSize` makes in the other direction.
1642
+ case WM_GETMINMAXINFO: {
1643
+ // The app's own window only. The limits are one set of numbers for one
1644
+ // window, because `useWindow()` is about the window an app is in --
1645
+ // answering this for every window would make Windows the one host where a
1646
+ // limit set for one applied to all of them. Per-window geometry is its
1647
+ // own piece of work and is in docs/BACKLOG.md.
1648
+ if (self != nullptr && self->surfaceId != kSurfaceId) {
1649
+ break;
1650
+ }
1651
+ const basalt::WindowSizeLimits limits = basalt::windowSizeLimits();
1652
+ if (limits.minWidth <= 0.0 && limits.minHeight <= 0.0 && limits.maxWidth <= 0.0 &&
1653
+ limits.maxHeight <= 0.0) {
1654
+ break;
1655
+ }
1656
+ RECT frame{0, 0, 0, 0};
1657
+ AdjustWindowRectEx(&frame,
1658
+ static_cast<DWORD>(GetWindowLongW(hwnd, GWL_STYLE)),
1659
+ FALSE,
1660
+ static_cast<DWORD>(GetWindowLongW(hwnd, GWL_EXSTYLE)));
1661
+ const LONG extraWidth = frame.right - frame.left;
1662
+ const LONG extraHeight = frame.bottom - frame.top;
1663
+
1664
+ auto *bounds = reinterpret_cast<MINMAXINFO *>(lparam);
1665
+ if (limits.minWidth > 0.0) {
1666
+ bounds->ptMinTrackSize.x = static_cast<LONG>(limits.minWidth) + extraWidth;
1667
+ }
1668
+ if (limits.minHeight > 0.0) {
1669
+ bounds->ptMinTrackSize.y = static_cast<LONG>(limits.minHeight) + extraHeight;
1670
+ }
1671
+ if (limits.maxWidth > 0.0) {
1672
+ bounds->ptMaxTrackSize.x = static_cast<LONG>(limits.maxWidth) + extraWidth;
1673
+ }
1674
+ if (limits.maxHeight > 0.0) {
1675
+ bounds->ptMaxTrackSize.y = static_cast<LONG>(limits.maxHeight) + extraHeight;
1676
+ }
1677
+ return 0;
1678
+ }
1679
+
1680
+ // The session is ending -- logout, shutdown, reboot -- which is the only
1681
+ // thing on Windows that corresponds to macOS's Cmd-Q. There is no
1682
+ // application-level quit gesture to intercept: Alt+F4 is a window close,
1683
+ // which arrives as WM_CLOSE below and is useCloseRequest's business.
1684
+ //
1685
+ // FALSE refuses the session end, which is what an app with unsaved work
1686
+ // wants and is the whole of this host's half. Windows may end the session
1687
+ // anyway -- a forced shutdown does not take no for an answer, and nothing
1688
+ // here can change that -- so what this buys is the ordinary case, which is
1689
+ // the one an app can act on.
1690
+ // A monitor plugged in, unplugged, or a resolution change. Not handled
1691
+ // -- DefWindowProc still gets it -- because this is a notification and
1692
+ // nothing here is answering it.
1693
+ case WM_DISPLAYCHANGE:
1694
+ basalt::notifyDisplaysChanged();
1695
+ break;
1696
+
1697
+ case WM_QUERYENDSESSION:
1698
+ if (basalt::hostQuitIntercepted()) {
1699
+ basalt::hostQuitRequested();
1700
+ return FALSE;
1701
+ }
1702
+ return TRUE;
1703
+
1704
+ // Before DestroyWindow, which takes every <TextInput>'s peer with it. See
1705
+ // captureBeforeTeardown.
1706
+ //
1707
+ // The app's own window only. A second window closing is not teardown, and
1708
+ // capturing there would be worse than pointless: `captureBeforeTeardown`
1709
+ // latches, so the dump taken as the window went would be the one the run
1710
+ // reported -- showing the window still open, because it is, and silencing
1711
+ // the dump at shutdown that was meant to show it gone.
1712
+ case WM_CLOSE:
1713
+ // Somebody is trying to close this window: its caption button, Alt+F4, or
1714
+ // the system menu. WM_CLOSE is the attempt rather than the act -- nothing
1715
+ // is destroyed until DestroyWindow below -- which is what makes it the
1716
+ // place a window that asked to be asked first can refuse.
1717
+ //
1718
+ // Every window, including the app's own: an app that wants to ask "are
1719
+ // you sure" wants to ask hardest about the one it is running in. What
1720
+ // happens next is its decision, and if that is "go ahead" it calls
1721
+ // close() itself.
1722
+ if (self != nullptr && basalt::hostWindowCloseIntercepted(self->surfaceId)) {
1723
+ basalt::hostWindowCloseRequested(self->surfaceId);
1724
+ return 0;
1725
+ }
1726
+ if (self == nullptr || self->surfaceId == kSurfaceId) {
1727
+ captureBeforeTeardown();
1728
+ }
1729
+ DestroyWindow(hwnd);
1730
+ return 0;
1731
+
1732
+ case WM_DESTROY:
1733
+ // Only the app's own window ends the process. A second window closing is
1734
+ // an app closing a window, and quitting on it would mean a preview panel
1735
+ // taking the whole application with it.
1736
+ if (self == nullptr || self->surfaceId == kSurfaceId) {
1737
+ PostQuitMessage(0);
1738
+ return 0;
1739
+ }
1740
+ // The app is already taking this window down; `closeHostWindow` is
1741
+ // mid-teardown and everything below is its job. See `closingFromApp`.
1742
+ if (self->closingFromApp) {
1743
+ return 0;
1744
+ }
1745
+ // Closed by the person rather than by the app: its own close button, or
1746
+ // Alt+F4. Without this the host keeps a record whose HWND is gone -- a
1747
+ // dangling handle rather than a stale flag -- and the `<Window>` that
1748
+ // opened it goes on believing it is open.
1749
+ //
1750
+ // The window is already being destroyed, so this cannot go through
1751
+ // closeHostWindow's teardown; what it does instead is stop the surface
1752
+ // and drop the record, which is the same work in the order this message
1753
+ // leaves available.
1754
+ {
1755
+ const facebook::react::SurfaceId closing = self->surfaceId;
1756
+ basalt::hostWindowClosed(closing);
1757
+ if (gHost.reactHost != nullptr) {
1758
+ gHost.reactHost->stopSurface(closing);
1759
+ }
1760
+ self->focusManager.reset();
1761
+ self->touchDispatcher.reset();
1762
+ self->target.Reset();
1763
+ self->window = nullptr;
1764
+ // The root outlives this message: the unmount mutations the stop
1765
+ // produces are still to come, and they name views in it. Dropped from
1766
+ // a round trip later, which is ordered behind them.
1767
+ if (gHost.reactHost != nullptr) {
1768
+ gHost.reactHost->runOnRuntimeScheduler([closing](facebook::jsi::Runtime &) {
1769
+ basalt::postToUiThread([closing] {
1770
+ HostWindow *going = gHost.windowFor(closing);
1771
+ if (going == nullptr) {
1772
+ return;
1773
+ }
1774
+ gHost.mountingManager->destroySurfaceRoot(closing);
1775
+ for (auto it = gHost.windows.begin(); it != gHost.windows.end(); ++it) {
1776
+ if (it->get() == going) {
1777
+ gHost.windows.erase(it);
1778
+ break;
1779
+ }
1780
+ }
1781
+ });
1782
+ });
1783
+ }
1784
+ }
1785
+ return 0;
1786
+
1787
+ default:
1788
+ break;
1789
+ }
1790
+ return DefWindowProc(hwnd, message, wparam, lparam);
1791
+ }
1792
+
1793
+ // Makes a window, a surface root for it, and the input that drives them.
1794
+ //
1795
+ // Called for the main window and for every one an app opens afterwards, which
1796
+ // is the point: the second window is not a special case of the first, it is the
1797
+ // same function with a different surface id. See core/WindowHost.h.
1798
+ //
1799
+ // The window is not shown and the surface is not started here; both are the
1800
+ // caller's business.
1801
+ HostWindow *createHostWindow(facebook::react::SurfaceId surfaceId,
1802
+ const wchar_t *title,
1803
+ int width,
1804
+ int height) {
1805
+ const HINSTANCE instance = GetModuleHandle(nullptr);
1806
+
1807
+ // Registered once for the process. A second call with the same name fails
1808
+ // with ERROR_CLASS_ALREADY_EXISTS, which is not an error here -- the class is
1809
+ // exactly what a second window wants.
1810
+ static bool classRegistered = false;
1811
+ if (!classRegistered) {
1812
+ WNDCLASSEX windowClass{};
1813
+ windowClass.cbSize = sizeof(windowClass);
1814
+ windowClass.style = CS_HREDRAW | CS_VREDRAW;
1815
+ windowClass.lpfnWndProc = hostProc;
1816
+ windowClass.hInstance = instance;
1817
+ windowClass.hCursor = LoadCursor(nullptr, IDC_ARROW);
1818
+ windowClass.lpszClassName = L"BasaltHost";
1819
+ if (RegisterClassEx(&windowClass) == 0) {
1820
+ return nullptr;
1821
+ }
1822
+ classRegistered = true;
1823
+ }
1824
+
1825
+ // WS_CLIPCHILDREN so that the Direct2D paint excludes any <TextInput> peer.
1826
+ // Without it the whole client area is painted and every EDIT child then
1827
+ // repaints itself on top, which is a visible flicker on every mount.
1828
+ constexpr DWORD kWindowStyle = WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN;
1829
+
1830
+ RECT wanted{0, 0, width, height};
1831
+ AdjustWindowRect(&wanted, kWindowStyle, FALSE);
1832
+
1833
+ auto owned = std::make_unique<HostWindow>();
1834
+ HostWindow *made = owned.get();
1835
+ made->surfaceId = surfaceId;
1836
+
1837
+ // The root before the window: hostProc answers messages CreateWindowEx sends
1838
+ // before it returns, and it finds this record by HWND -- which is not set
1839
+ // yet, so those messages go to DefWindowProc. Pushing the record afterwards
1840
+ // is what keeps that true rather than half-true.
1841
+ made->root = gHost.mountingManager->createSurfaceRoot(surfaceId);
1842
+ made->root->setFrame(0, 0, static_cast<float>(width), static_cast<float>(height));
1843
+
1844
+ made->window = CreateWindowEx(0,
1845
+ L"BasaltHost",
1846
+ title,
1847
+ kWindowStyle,
1848
+ CW_USEDEFAULT,
1849
+ CW_USEDEFAULT,
1850
+ wanted.right - wanted.left,
1851
+ wanted.bottom - wanted.top,
1852
+ nullptr,
1853
+ nullptr,
1854
+ instance,
1855
+ nullptr);
1856
+ if (made->window == nullptr) {
1857
+ gHost.mountingManager->destroySurfaceRoot(surfaceId);
1858
+ return nullptr;
1859
+ }
1860
+
1861
+ // Input, per window. The dispatcher holds this window's root, not a surface,
1862
+ // so it outlives every transaction mounted into it -- and a second window
1863
+ // with the first one's would deliver every press to the wrong tree.
1864
+ made->touchDispatcher =
1865
+ std::make_unique<basalt::Win32TouchDispatcher>(gHost.mountingManager.get(), made->root);
1866
+ // The keyboard half: Tab reaching a <Pressable>, and Enter activating it.
1867
+ // All of it is this project's -- a React Native view is not a window here, so
1868
+ // there is nothing for Windows to focus. See Win32Focus.h.
1869
+ made->focusManager =
1870
+ std::make_unique<basalt::Win32FocusManager>(gHost.mountingManager.get(), made->root);
1871
+
1872
+ gHost.windows.push_back(std::move(owned));
1873
+ return made;
1874
+ }
1875
+
1876
+ bool createWindow() {
1877
+ return createHostWindow(kSurfaceId, L"basalt-core \x2014 Windows", kInitialWidth,
1878
+ kInitialHeight) != nullptr;
1879
+ }
1880
+
1881
+ } // namespace
1882
+
1883
+ namespace basalt {
1884
+
1885
+ // core/WindowHost.h, on Windows.
1886
+ //
1887
+ // The surface id is allocated here rather than by JavaScript: it is Fabric's
1888
+ // number, the mounting manager keys its roots by it, and two windows racing to
1889
+ // pick one would be two windows sharing a root. Above the ids this host
1890
+ // reserves -- the app's surface is 1 and the error inspector's is 2.
1891
+ facebook::react::SurfaceId openHostWindow(const NewWindowOptions &options) {
1892
+ if (gHost.reactHost == nullptr || !gHost.surfaceStarted || options.component.empty() ||
1893
+ gHost.windows.empty()) {
1894
+ return 0;
1895
+ }
1896
+
1897
+ static facebook::react::SurfaceId nextSurfaceId = kLogBoxSurfaceId + 1;
1898
+ const facebook::react::SurfaceId surfaceId = nextSurfaceId++;
1899
+
1900
+ const int width = static_cast<int>(options.width);
1901
+ const int height = static_cast<int>(options.height);
1902
+ const std::wstring title = basalt::win32::widen(options.title);
1903
+ HostWindow *made = createHostWindow(surfaceId, title.c_str(), width, height);
1904
+ if (made == nullptr) {
1905
+ return 0;
1906
+ }
1907
+
1908
+ gHost.reactHost->startSurface(surfaceId,
1909
+ options.component,
1910
+ options.props,
1911
+ constraintsFor(width, height),
1912
+ layoutContextFor(gHost.scaleFactor));
1913
+ ShowWindow(made->window, SW_SHOW);
1914
+ UpdateWindow(made->window);
1915
+ std::fprintf(stderr,
1916
+ "opened window %d for module %s\n",
1917
+ static_cast<int>(surfaceId),
1918
+ options.component.c_str());
1919
+ return surfaceId;
1920
+ }
1921
+
1922
+ // Ends the application. `PostQuitMessage` rather than `DestroyWindow` on
1923
+ // each: it stops the message loop whatever is open, which is what a person
1924
+ // choosing Quit means, and the ordinary teardown follows it.
1925
+ void quitHost() {
1926
+ PostQuitMessage(0);
1927
+ }
1928
+
1929
+ void closeHostWindow(facebook::react::SurfaceId surfaceId) {
1930
+ // The main window is not closed this way: destroying the surface an app is
1931
+ // running in is not the same thing as closing its window, and an app that
1932
+ // means the second should say so through `close()` on the window itself.
1933
+ if (gHost.reactHost == nullptr || surfaceId == kSurfaceId || surfaceId == kLogBoxSurfaceId) {
1934
+ return;
1935
+ }
1936
+ if (gHost.windowFor(surfaceId) == nullptr) {
1937
+ return;
1938
+ }
1939
+
1940
+ // Stopping a surface unmounts its React tree, which produces one last
1941
+ // transaction of Remove and Delete mutations. Those arrive on the UI thread
1942
+ // afterwards, and the views they name have to still be there when they do.
1943
+ gHost.reactHost->stopSurface(surfaceId);
1944
+
1945
+ // So the window goes a round trip later: out to the JavaScript thread, which
1946
+ // is where the teardown runs, and back to this one, which is where its
1947
+ // mutations are applied. Both queues are ordered, so anything the stop
1948
+ // produced is ahead of this.
1949
+ gHost.reactHost->runOnRuntimeScheduler([surfaceId](facebook::jsi::Runtime &) {
1950
+ basalt::postToUiThread([surfaceId] {
1951
+ HostWindow *going = gHost.windowFor(surfaceId);
1952
+ if (going == nullptr) {
1953
+ return;
1954
+ }
1955
+ // The dispatchers before the views they hold: both keep a borrowed root.
1956
+ going->focusManager.reset();
1957
+ going->touchDispatcher.reset();
1958
+ // The target before the window it belongs to, and the window before the
1959
+ // root the mutations named. DestroyWindow takes any <TextInput> peer
1960
+ // parented to it with it, which is why nothing here frees one by hand.
1961
+ going->target.Reset();
1962
+ // Before DestroyWindow, because DestroyWindow sends WM_DESTROY on this
1963
+ // thread and synchronously: the handler runs before the call returns, and
1964
+ // has to know this is the app's own doing.
1965
+ going->closingFromApp = true;
1966
+ // Null when WM_DESTROY got here first, which is a window the person
1967
+ // closed rather than the app.
1968
+ if (going->window != nullptr) {
1969
+ DestroyWindow(going->window);
1970
+ }
1971
+ gHost.mountingManager->destroySurfaceRoot(surfaceId);
1972
+
1973
+ for (auto it = gHost.windows.begin(); it != gHost.windows.end(); ++it) {
1974
+ if (it->get() == going) {
1975
+ gHost.windows.erase(it);
1976
+ break;
1977
+ }
1978
+ }
1979
+ std::fprintf(stderr, "closed window %d\n", static_cast<int>(surfaceId));
1980
+ });
1981
+ });
1982
+ }
1983
+
1984
+ std::vector<facebook::react::SurfaceId> hostWindows() {
1985
+ std::vector<facebook::react::SurfaceId> open;
1986
+ open.reserve(gHost.windows.size());
1987
+ for (const auto &candidate : gHost.windows) {
1988
+ open.push_back(candidate->surfaceId);
1989
+ }
1990
+ return open;
1991
+ }
1992
+
1993
+ } // namespace basalt
1994
+
1995
+ int main(int argc, char **argv) {
1996
+ // glog, before anything logs through it.
1997
+ //
1998
+ // Without this every run opens with "WARNING: Logging before
1999
+ // InitGoogleLogging() is written to STDERR" -- glog saying it has not been
2000
+ // told where to write, in a line that reads like something went wrong on
2001
+ // start-up. It goes to stderr either way; this only stops it apologising for
2002
+ // it. React Native's C++ logs through glog throughout, so the first message
2003
+ // arrives before anything this project writes.
2004
+ FLAGS_logtostderr = true;
2005
+ google::InitGoogleLogging(argv[0]);
2006
+
2007
+ gHost.bundlePath = argc > 1 ? argv[1] : "build/main.jsbundle.js";
2008
+ // Defaults to the raw-Fabric script, because a React screen needs
2009
+ // <ScrollView> and <TextInput> that Windows does not mount yet and would
2010
+ // render with holes in it. A React app that stays inside <View>, <Text>,
2011
+ // <Image> and <Pressable> -- js/press.js, say -- runs from here.
2012
+ gHost.moduleName = argc > 2 ? argv[2] : "";
2013
+ // Metro's entry, as the GTK and AppKit hosts read it: BASALT_DEV_ENTRY, which
2014
+ // is what `run-windows` sets, and "index" otherwise. A third argument still
2015
+ // wins for anyone passing one. This used to read the argument alone, which
2016
+ // nothing passes, so sourcePath was empty in every development run --
2017
+ // DevServerHelper then builds no URL at all ("Failed to download JS bundle
2018
+ // from Url: ."), and loadScript quietly fell back to the on-disk bundle: the
2019
+ // window opened, and no edit ever reached it.
2020
+ if (argc > 3) {
2021
+ gHost.sourcePath = argv[3];
2022
+ } else if (const char *entry = std::getenv("BASALT_DEV_ENTRY")) {
2023
+ gHost.sourcePath = entry;
2024
+ } else {
2025
+ gHost.sourcePath = "index";
2026
+ }
2027
+
2028
+ // A URL the desktop launched this with, for `Linking.getInitialURL()`. Any
2029
+ // argument after the bundle and the module that carries a scheme: a shell
2030
+ // association passes one, and it does not know what came before. `://` rather
2031
+ // than a bare colon, so a Windows path like `C:\bundle.js` is not mistaken
2032
+ // for one.
2033
+ for (int i = 1; i < argc; i++) {
2034
+ const std::string argument(argv[i]);
2035
+ if (argument.find("://") != std::string::npos) {
2036
+ basalt::setInitialUrl(argument);
2037
+ break;
2038
+ }
2039
+ }
2040
+
2041
+ // Per-monitor DPI, declared in code rather than in a manifest so that running
2042
+ // the binary straight out of the build directory behaves the same as running
2043
+ // an installed one. Without it Windows scales the window's bitmap and every
2044
+ // line goes soft on a high-DPI display.
2045
+ SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
2046
+
2047
+ // COM, on the thread that will outlive every other one.
2048
+ //
2049
+ // WIC decodes an <Image> on a worker, and the IWICBitmap it produces is
2050
+ // released here when the view holding it is deleted. A release in a thread
2051
+ // with no apartment at all is what that used to be, and the process exited
2052
+ // 0xC0000005 every time an app rendered an image. ShellExecute wants an
2053
+ // initialised thread too, and that is how core/PlatformServices opens a URL.
2054
+ //
2055
+ // Apartment-threaded because this thread owns a window and pumps messages,
2056
+ // which is what an STA is for.
2057
+ // OleInitialize rather than CoInitializeEx: it initialises the same
2058
+ // apartment and additionally the OLE machinery RegisterDragDrop needs. A
2059
+ // thread that only called CoInitializeEx can open a URL and cannot accept a
2060
+ // drop, and the failure is a DRAGDROP_E_NOTREGISTERED nobody looks for.
2061
+ OleInitialize(nullptr);
2062
+
2063
+ // Before anything posts: this is what makes postToUiThread marshal rather
2064
+ // than run inline, and the mounting manager depends on it. See
2065
+ // win32/Win32UiThread.h.
2066
+ basalt::installUiThread();
2067
+
2068
+ // Before any window exists, which is the part that matters: the shell reads
2069
+ // a process's AppUserModelID when its first window is shown and does not
2070
+ // notice a later change. What the id buys is a notification Windows will
2071
+ // attribute to this application rather than refuse. Needs COM, which is
2072
+ // already initialised above. See win32/Win32Packaging.h.
2073
+ basalt::win32::applyPackaging(basalt::appIdentity(gHost.bundlePath));
2074
+
2075
+ if (FAILED(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED,
2076
+ gHost.d2dFactory.GetAddressOf()))) {
2077
+ std::fprintf(stderr, "could not create a Direct2D factory\n");
2078
+ return 1;
2079
+ }
2080
+ // Before the window, which is the ordering multiple windows imposes: a window
2081
+ // makes a surface root, and only the mounting manager can. Constructed on
2082
+ // this thread, which it records and asserts every mutation lands back on.
2083
+ gHost.mountingManager = std::make_shared<basalt::Win32MountingManager>();
2084
+
2085
+ if (!createWindow()) {
2086
+ std::fprintf(stderr, "could not create the window\n");
2087
+ return 1;
2088
+ }
2089
+
2090
+ // The app's Expo config, if it is an Expo app and has been bundled. Before the
2091
+ // runtime is installed, as the other two hosts load it -- this one never did,
2092
+ // so Constants.expoConfig was null on Windows. It also names the window.
2093
+ basalt::loadExpoAppConfigBeside(gHost.bundlePath);
2094
+ {
2095
+ const std::string appName = basalt::expoAppName();
2096
+ basalt::titleBar().setDefaultTitle(
2097
+ !appName.empty() ? appName
2098
+ : (!gHost.moduleName.empty() ? gHost.moduleName : "basalt-core"));
2099
+ }
2100
+ // Dark or light to match the app, and again whenever that changes -- in
2101
+ // Settings, or through Appearance.setColorScheme on the JavaScript thread, so
2102
+ // the change is marshalled here from wherever it was announced.
2103
+ basalt::titleBar().setDarkMode(basalt::effectiveColorScheme() == basalt::ColorScheme::Dark);
2104
+ basalt::titleBar().attach(gHost.main().window);
2105
+ basalt::addColorSchemeObserver([](basalt::ColorScheme scheme) {
2106
+ basalt::postToUiThread(
2107
+ [scheme] { basalt::titleBar().setDarkMode(scheme == basalt::ColorScheme::Dark); });
2108
+ });
2109
+
2110
+ UINT dpi = GetDpiForWindow(gHost.main().window);
2111
+ gHost.scaleFactor = dpi >= 96 ? static_cast<int>(dpi / 96) : 1;
2112
+
2113
+ // Bridgeless. React Native's C++ host is the bridgeless one, and there is no
2114
+ // bridge here to be the alternative -- so it asserts that.
2115
+ facebook::react::ReactNativeFeatureFlags::override(
2116
+ std::make_unique<DesktopFeatureFlags>());
2117
+
2118
+ gHost.runLoopObserverManager = std::make_shared<RunLoopObserverManager>();
2119
+ gHost.choreographer = std::make_shared<basalt::Win32AnimationChoreographer>();
2120
+
2121
+ // Every mounted transaction has to reach the screen. Windows repaints on
2122
+ // demand, so this is the difference between mutations arriving and anything
2123
+ // being visible.
2124
+ gHost.mountingManager->setOnDidMount(syncPeersAndRepaint);
2125
+
2126
+ // Prime the bounds cache, which `getBounds()` answers from: without this an
2127
+ // app's first render sees a window of no size, and only a later resize
2128
+ // corrects it. See core/WindowBoundsCache.cpp.
2129
+ basalt::notifyWindowBoundsChanged();
2130
+
2131
+ // And the display list, for the same reason and off the same seam: an app
2132
+ // asking which screens exist before anything has been plugged or unplugged
2133
+ // should not be told there are none.
2134
+ basalt::notifyDisplaysChanged();
2135
+
2136
+ // Accepting what the desktop drags onto this window. Per window rather than
2137
+ // per view: OLE registers a target against an HWND, and which *app* view is
2138
+ // under the pointer is core/DragAndDrop.h's question.
2139
+ basalt::attachDropTarget(gHost.main().window, gHost.main().root);
2140
+
2141
+ // Every <TextInput>'s EDIT peer is a child of this window. Set before the
2142
+ // first transaction, because a field that mounts without one gets no control
2143
+ // and no second chance -- `update` only creates on first sight.
2144
+ gHost.mountingManager->setHostWindow(gHost.main().window);
2145
+
2146
+ // Light or dark, and any change to it. Before ReactHost, so the module can
2147
+ // answer from the first query.
2148
+ basalt::startObservingColorScheme();
2149
+
2150
+ ReactInstanceConfig config;
2151
+ // The appId is how this host tells Metro which platform it is; see
2152
+ // APP_ID_PREFIX in packages/basalt-core/metro-config.js.
2153
+ config.appId = "basalt-windows";
2154
+ config.deviceName = "windows";
2155
+
2156
+ // Dev mode changes three things at once: loadScript tries Metro before the
2157
+ // on-disk bundle; DevServerHelper exists, which is the only condition under
2158
+ // which ReactCxxTurboModuleProvider serves the DevSettings module a __DEV__
2159
+ // bundle requires; and ReactHost opens a packager connection whose reload
2160
+ // message reloads the instance.
2161
+ config.enableDevMode = std::getenv("BASALT_DEV") != nullptr;
2162
+ gHost.devMode = config.enableDevMode;
2163
+ config.enableInspector = config.enableDevMode;
2164
+ if (const char *devHost = std::getenv("BASALT_DEV_HOST")) {
2165
+ config.devServerHost = devHost;
2166
+ }
2167
+ if (const char *devPort = std::getenv("BASALT_DEV_PORT")) {
2168
+ config.devServerPort = static_cast<uint32_t>(std::strtoul(devPort, nullptr, 10));
2169
+ }
2170
+ // So the http client can tell the bundle request apart from an app's own
2171
+ // fetch, and refuse to feed a Metro error page to the JS engine. See
2172
+ // core/DevBundle.h.
2173
+ if (config.enableDevMode) {
2174
+ basalt::setDevServerOrigin(config.devServerHost, config.devServerPort);
2175
+ }
2176
+
2177
+ try {
2178
+ gHost.reactHost = std::make_unique<ReactHost>(
2179
+ config,
2180
+ gHost.mountingManager,
2181
+ gHost.runLoopObserverManager,
2182
+ makeContextContainer(),
2183
+ facebook::react::getDefaultOnJsErrorFunc(),
2184
+ logToConsole,
2185
+ nullptr,
2186
+ makeTurboModuleProviders(
2187
+ basalt::scriptURLFor(gHost.bundlePath,
2188
+ config.enableDevMode,
2189
+ config.devServerHost,
2190
+ config.devServerPort,
2191
+ gHost.sourcePath.empty() ? "index" : gHost.sourcePath,
2192
+ "windows"),
2193
+ config.enableDevMode,
2194
+ gHost.mountingManager->imageLoader()),
2195
+ // React Native's error inspector. ReactCxxPlatform implements the
2196
+ // LogBox TurboModule itself and only provides it when a host hands over
2197
+ // one of these; passing null, as this did, left NativeLogBox.show() a
2198
+ // call into nothing. See core/LogBoxSurface.h.
2199
+ std::make_shared<basalt::LogBoxSurfaceDelegate>(
2200
+ [](const std::string &appKey) { showLogBoxSurface(appKey); },
2201
+ []() { hideLogBoxSurface(); }),
2202
+ // `useNativeDriver: true`. ReactCommon has a C++ implementation of the
2203
+ // whole animated graph and ReactCxxPlatform provides the module for it,
2204
+ // but only when a host asks by handing over a provider. Without it every
2205
+ // native-driven Animated call throws "Native animated module is not
2206
+ // available".
2207
+ std::make_shared<facebook::react::NativeAnimatedNodesManagerProvider>(),
2208
+ installBindings,
2209
+ gHost.choreographer);
2210
+ } catch (const std::exception &error) {
2211
+ std::fprintf(stderr, "could not construct ReactHost: %s\n", error.what());
2212
+ return 1;
2213
+ }
2214
+
2215
+ // How anything in the core reaches the scheduler, which only the host has.
2216
+ // Reanimated is the one caller; see core/UIManagerAccess.h.
2217
+ basalt::setEventListenerInstaller(
2218
+ [](std::shared_ptr<const facebook::react::EventListener> listener) {
2219
+ if (gHost.reactHost == nullptr) {
2220
+ return;
2221
+ }
2222
+ gHost.reactHost->runOnScheduler(
2223
+ [listener = std::move(listener)](facebook::react::Scheduler &scheduler) {
2224
+ scheduler.addEventListener(listener);
2225
+ });
2226
+ });
2227
+
2228
+ // And how anything in the core reaches the JavaScript thread. An Expo
2229
+ // module is given no call invoker, so this is the only way one can emit an
2230
+ // event or settle a promise later; see core/JsRuntimeAccess.h.
2231
+ basalt::setRuntimeRunner(
2232
+ [](std::function<void(facebook::jsi::Runtime &)> work) {
2233
+ if (gHost.reactHost == nullptr) {
2234
+ return;
2235
+ }
2236
+ gHost.reactHost->runOnRuntimeScheduler(std::move(work));
2237
+ });
2238
+
2239
+ // `loadScript` falls back to the on-disk bundle whenever the Metro fetch
2240
+ // fails, which is right when nothing is listening and wrong when Metro
2241
+ // answered with an error: running the last bundle that built, while the
2242
+ // developer looks at source that is not what is executing, hides the very
2243
+ // thing they need to see. So the fallback is allowed only for the first case,
2244
+ // and the second stops here with Metro's own message.
2245
+ const bool loaded = gHost.reactHost->loadScript(gHost.bundlePath, gHost.sourcePath);
2246
+ if (const auto devError = basalt::devBundleError()) {
2247
+ std::fprintf(stderr,
2248
+ "Metro could not build the bundle (HTTP %ld):\n%s\n",
2249
+ static_cast<long>(devError->status),
2250
+ devError->message.c_str());
2251
+ // Through shutdown() rather than straight out: the fallback bundle is
2252
+ // already running on the JS thread, and dropping the host without joining
2253
+ // it aborts in a destructor instead of exiting.
2254
+ shutdown();
2255
+ return 1;
2256
+ }
2257
+ if (!loaded) {
2258
+ std::fprintf(stderr, "could not load script: %s\n", gHost.bundlePath.c_str());
2259
+ shutdown();
2260
+ return 1;
2261
+ }
2262
+ // Where it came from, not where it might have. See core/DevBundle.h.
2263
+ if (const auto from = basalt::devBundleFetched()) {
2264
+ std::fprintf(stderr, "loaded script from the packager: %s\n", from->c_str());
2265
+ } else {
2266
+ std::fprintf(stderr, "loaded script: %s\n", gHost.bundlePath.c_str());
2267
+ }
2268
+
2269
+ // The module name decides who drives the surface. Non-empty:
2270
+ // SurfaceHandler::start calls AppRegistry.runApplication. Empty: the surface
2271
+ // is registered without JS being called at all, leaving it for a script to
2272
+ // commit into through nativeFabricUIManager by hand.
2273
+ gHost.reactHost->startSurface(kSurfaceId,
2274
+ gHost.moduleName,
2275
+ folly::dynamic::object(),
2276
+ constraintsFor(kInitialWidth, kInitialHeight),
2277
+ layoutContextFor(gHost.scaleFactor));
2278
+ gHost.surfaceStarted = true;
2279
+ std::fprintf(stderr,
2280
+ "started surface %d%s%s\n",
2281
+ static_cast<int>(kSurfaceId),
2282
+ gHost.moduleName.empty() ? " (no module; raw Fabric script)" : " for module ",
2283
+ gHost.moduleName.c_str());
2284
+
2285
+ ShowWindow(gHost.main().window, SW_SHOWNORMAL);
2286
+ UpdateWindow(gHost.main().window);
2287
+
2288
+ if (gHost.moduleName.empty()) {
2289
+ std::fprintf(stderr, "--- committing tree 1 from JS ---\n");
2290
+ callRenderFunction(1);
2291
+ SetTimer(gHost.main().window, kSecondTreeTimer, 2000, nullptr);
2292
+ }
2293
+
2294
+ UINT scriptedDelayMs = 1500;
2295
+ if (const char *taps = std::getenv("BASALT_TEST_TAP")) {
2296
+ scriptedDelayMs = scheduleTestPoints(taps, scriptedDelayMs, ScriptedInput::Kind::Tap);
2297
+ }
2298
+ // BASALT_TEST_SECONDARY_TAP: the other button, which presses nothing and
2299
+ // arrives as a pointer event. See core/PointerButtons.h.
2300
+ if (const char *taps = std::getenv("BASALT_TEST_SECONDARY_TAP")) {
2301
+ scriptedDelayMs = scheduleTestPoints(
2302
+ taps, scriptedDelayMs, ScriptedInput::Kind::Tap, basalt::PointerButton::Secondary);
2303
+ }
2304
+ // BASALT_TEST_HOVER: the pointer moving with no button down. A negative point
2305
+ // means it left the window, which is what WM_MOUSELEAVE reports.
2306
+ if (const char *hovers = std::getenv("BASALT_TEST_HOVER")) {
2307
+ scriptedDelayMs = scheduleTestPoints(hovers, scriptedDelayMs, ScriptedInput::Kind::Hover);
2308
+ }
2309
+ if (const char *drag = std::getenv("BASALT_TEST_DRAG")) {
2310
+ scriptedDelayMs = scheduleTestDrag(drag, scriptedDelayMs);
2311
+ }
2312
+ if (const char *scrolls = std::getenv("BASALT_TEST_SCROLL")) {
2313
+ scriptedDelayMs = scheduleTestScrolls(scrolls, scriptedDelayMs);
2314
+ }
2315
+ // BASALT_TEST_CLOSE_WINDOW: the surface id of a window to close the way a
2316
+ // person would -- its own close button, not the app asking. The two take
2317
+ // different paths through the host and only one of them can leave a record
2318
+ // whose window is gone, which is why it is worth being able to drive.
2319
+ if (const char *closing = std::getenv("BASALT_TEST_CLOSE_WINDOW")) {
2320
+ scriptedDelayMs = scheduleScriptedInput(
2321
+ ScriptedInput{.kind = ScriptedInput::Kind::Close,
2322
+ .surfaceId = static_cast<facebook::react::SurfaceId>(std::atoi(closing))},
2323
+ scriptedDelayMs);
2324
+ }
2325
+
2326
+ // BASALT_TEST_DROP: "x,y:path" -- a file dropped at that point. On all three
2327
+ // hosts, because a scenario that uses an instrument two of them have is a
2328
+ // scenario that fails on the third for a reason that has nothing to do with
2329
+ // what it tests. Learned twice: BASALT_TEST_QUIT did the same.
2330
+ if (const char *drop = std::getenv("BASALT_TEST_DROP")) {
2331
+ const std::string spec(drop);
2332
+ const size_t comma = spec.find(',');
2333
+ const size_t colon = comma == std::string::npos ? std::string::npos : spec.find(':', comma);
2334
+ if (comma == std::string::npos || colon == std::string::npos) {
2335
+ std::fprintf(stderr, "BASALT_TEST_DROP: expected x,y:path\n");
2336
+ } else {
2337
+ scriptedDelayMs = scheduleScriptedInput(
2338
+ ScriptedInput{.kind = ScriptedInput::Kind::Drop,
2339
+ .fromX = std::strtod(spec.substr(0, comma).c_str(), nullptr),
2340
+ .fromY = std::strtod(spec.substr(comma + 1, colon - comma - 1).c_str(),
2341
+ nullptr),
2342
+ .text = spec.substr(colon + 1)},
2343
+ scriptedDelayMs);
2344
+ }
2345
+ }
2346
+
2347
+ // BASALT_TEST_QUIT: ask the application to quit the way a person does. A
2348
+ // count rather than a flag, for the reason the other hosts give: one ask
2349
+ // cannot show both a refusal and the agreement after it.
2350
+ if (const char *asks = std::getenv("BASALT_TEST_QUIT")) {
2351
+ const int times = std::max(1, std::atoi(asks));
2352
+ for (int ask = 0; ask < times; ask++) {
2353
+ scriptedDelayMs = scheduleScriptedInput(
2354
+ ScriptedInput{.kind = ScriptedInput::Kind::Quit}, scriptedDelayMs);
2355
+ }
2356
+ }
2357
+
2358
+ // BASALT_TEST_FOCUS: keyboard actions separated by ';' -- `tab`, `shift-tab`,
2359
+ // `activate`, `escape` and `devmenu`. The same reason the other instruments exist: a
2360
+ // real Tab needs a window the system considers focused, which an automated
2361
+ // run does not reliably have.
2362
+ // BASALT_TEST_KEY: a declared shortcut, pressed. The step this host was missing
2363
+ // -- the delivery and the WM_KEYDOWN hook were here and nothing scheduled
2364
+ // anything, so the scenario's own "did the instrument run at all" guard failed
2365
+ // rather than an assertion about keys. Which is what that guard is for.
2366
+ for (const basalt::KeyCombination &pressed : basalt::scriptedKeyPresses()) {
2367
+ scriptedDelayMs = scheduleScriptedInput(
2368
+ ScriptedInput{.kind = ScriptedInput::Kind::Key, .pressed = pressed}, scriptedDelayMs);
2369
+ }
2370
+
2371
+ if (const char *focus = std::getenv("BASALT_TEST_FOCUS")) {
2372
+ const std::string all(focus);
2373
+ size_t start = 0;
2374
+ while (start <= all.size()) {
2375
+ const size_t semicolon = all.find(';', start);
2376
+ const std::string action =
2377
+ all.substr(start, semicolon == std::string::npos ? std::string::npos : semicolon - start);
2378
+ if (!action.empty()) {
2379
+ scriptedDelayMs = scheduleScriptedInput(
2380
+ ScriptedInput{.kind = ScriptedInput::Kind::Focus, .text = action}, scriptedDelayMs);
2381
+ }
2382
+ if (semicolon == std::string::npos) {
2383
+ break;
2384
+ }
2385
+ start = semicolon + 1;
2386
+ }
2387
+ }
2388
+
2389
+ // BASALT_TEST_TYPE: text for whichever field has focus. Last, so that a tap
2390
+ // scheduled above has already put focus somewhere.
2391
+ if (const char *text = std::getenv("BASALT_TEST_TYPE")) {
2392
+ scriptedDelayMs = scheduleScriptedInput(
2393
+ ScriptedInput{.kind = ScriptedInput::Kind::Type, .text = text}, scriptedDelayMs);
2394
+ }
2395
+
2396
+ // What the schedule above came to, so BASALT_QUIT_WHEN_SETTLED waits for the
2397
+ // last of it rather than only for the first mount. Said once, after everything
2398
+ // that moves the total. See core/TestSettle.h.
2399
+ basalt::noteScriptedInputEndsInMs(scriptedDelayMs);
2400
+
2401
+ // BASALT_QUIT_AFTER_MS, so an automated run terminates without anyone
2402
+ // clicking anything. The same escape hatch both other hosts have.
2403
+ if (const char *quitAfter = std::getenv("BASALT_QUIT_AFTER_MS")) {
2404
+ const UINT delay = static_cast<UINT>(std::strtoul(quitAfter, nullptr, 10));
2405
+ SetTimer(
2406
+ gHost.main().window, kQuitAfterTimer, delay, [](HWND hwnd, UINT, UINT_PTR id, DWORD) {
2407
+ KillTimer(hwnd, id);
2408
+ // The same line the other two hosts log, so a person reading three
2409
+ // transcripts side by side sees the same shutdown in each. This
2410
+ // host was silent about it, which is how a scenario came to assert
2411
+ // on a string only two of them print.
2412
+ std::fprintf(stderr, "BASALT_QUIT_AFTER_MS elapsed; quitting\n");
2413
+ // What WM_CLOSE does, without going through WM_CLOSE. This is the
2414
+ // harness ending the run, not a person closing a window, and an app
2415
+ // that intercepts its own close would otherwise refuse it -- a test
2416
+ // that asks "does refusing work" by never exiting.
2417
+ captureBeforeTeardown();
2418
+ DestroyWindow(hwnd);
2419
+ });
2420
+ }
2421
+
2422
+ // BASALT_TEST_QUIT_FILE: the same shutdown, at a moment the harness picks
2423
+ // rather than on a budget fixed before launch. captureBeforeTeardown and
2424
+ // DestroyWindow either way, so the widget tree is dumped -- which is the whole
2425
+ // reason this instrument exists. TerminateProcess, which is what
2426
+ // Popen.terminate() is on this platform, reaches neither, so before this the
2427
+ // scenarios that read the tree could not be run here at all.
2428
+ //
2429
+ // See core/TestQuitFile.h.
2430
+ //
2431
+ // Two instruments, one timer, because they want the same thing at different
2432
+ // moments: the quit file is the harness deciding, BASALT_QUIT_WHEN_SETTLED is
2433
+ // the app noticing it has nothing left to do. One timer is also one id, and
2434
+ // SetTimer replaces silently on a collision. See core/TestSettle.h.
2435
+ if (basalt::testQuitFilePath().has_value() || basalt::quitWhenSettledMs().has_value()) {
2436
+ SetTimer(gHost.main().window,
2437
+ kQuitFileTimer,
2438
+ basalt::kTestQuitFilePollMs,
2439
+ [](HWND hwnd, UINT, UINT_PTR id, DWORD) {
2440
+ const char *why = nullptr;
2441
+ if (basalt::testQuitFileAppeared()) {
2442
+ why = basalt::kTestQuitFileMessage;
2443
+ } else if (basalt::hasSettled()) {
2444
+ why = basalt::kQuitWhenSettledMessage;
2445
+ } else {
2446
+ return;
2447
+ }
2448
+ KillTimer(hwnd, id);
2449
+ std::fprintf(stderr, "%s\n", why);
2450
+ captureBeforeTeardown();
2451
+ DestroyWindow(hwnd);
2452
+ });
2453
+ }
2454
+
2455
+ // Not a plain GetMessage loop: the event beat has to be induced each time the
2456
+ // queue empties, and Win32 has no "about to wait" hook to install one into.
2457
+ // See win32/Win32RunLoopObserver.h.
2458
+ const int exitCode = basalt::runMessageLoopWithBeat(gHost.runLoopObserverManager);
2459
+
2460
+ // Before the window goes: OLE holds a reference to the drop target, and a
2461
+ // revoked target is one it can release. Leaving it registered would leave
2462
+ // OLE pointing at a destroyed HWND.
2463
+ basalt::detachDropTarget(gHost.windows.empty() ? nullptr : gHost.main().window);
2464
+
2465
+ shutdown();
2466
+ // After shutdown, which is where the last WIC bitmap is released.
2467
+ // OleUninitialize rather than CoUninitialize, to match the OleInitialize
2468
+ // above: it undoes both halves.
2469
+ OleUninitialize();
2470
+ return exitCode;
2471
+ }
2472
+
2473
+ // What is missing is now two components rather than the whole of input:
2474
+ // <ScrollView> and <TextInput>. Both other hosts have them, and an ordinary
2475
+ // React screen renders with holes in it without them, which is why this host
2476
+ // still defaults to the raw-Fabric script rather than to a React app.
2477
+ //
2478
+ // Keyboard input is no longer among them. A <TextInput>'s peer is a real child
2479
+ // window and takes its own keys; everything else is reached by Tab through
2480
+ // Win32Focus.h, which owns the whole chain because a React Native view here is
2481
+ // not a window and so has no focus of Windows' own.