basalt-win32 0.0.0-stage → 0.2.0

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