basalt-win32 0.2.7 → 0.2.8

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.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # basalt-win32
2
+
3
+ The Windows half of [Basalt](https://basaltjs.dev): a Win32 and Direct2D view
4
+ layer, a Fabric mounting manager, and a host that runs your React Native app
5
+ as a native application.
6
+
7
+ ```bash
8
+ npx basalt-core init # adds this package when app.json asks for windows
9
+ npm run windows -- --build
10
+ ```
11
+
12
+ You rarely install this by hand. `basalt init` reads
13
+ [`basalt.desktops`](https://basaltjs.dev/docs/reference/app-json#basaltdesktops)
14
+ and adds the host packages the app asked for.
15
+
16
+ ## What it gives you
17
+
18
+ Win32 windows, Direct2D for everything drawn, and DirectWrite for every line
19
+ of text. Screen readers are answered through UI Automation. The caption
20
+ buttons stay the system's, and an app that draws its own header answers
21
+ `HTCAPTION` for the parts of it that should move the window.
22
+
23
+ A toast needs an AppUserModelID and a Start Menu shortcut carrying it, and the
24
+ host registers both itself at startup rather than from Node, because a `.lnk`
25
+ with that property set means `IPropertyStore`, which means COM.
26
+
27
+ It registers the `react-native run-windows` command.
28
+
29
+ **Windows is the least finished of the three desktops**, and
30
+ [the support table](https://basaltjs.dev/docs/support) says exactly where
31
+ rather than leaving you to find out: 124 of 137 props at the time of writing,
32
+ against 126 on Linux and 130 on macOS, with a reason beside every gap.
33
+
34
+ ## What you need to build it
35
+
36
+ CMake, Ninja, [vcpkg](https://vcpkg.io) with `VCPKG_INSTALLATION_ROOT`
37
+ pointing at it, and **`clang-cl`**, not just `cl`: Hermes uses
38
+ `__builtin_expect`, which MSVC's compiler does not have.
39
+
40
+ `npx basalt-core doctor` checks all of that in seconds. Worth doing before a
41
+ first build that compiles Hermes and React Native's C++ from source and takes
42
+ tens of minutes.
43
+
44
+ Ninja is what development and CI use. The Visual Studio generator works too,
45
+ and CI builds it on every release so that opening the project in Visual Studio
46
+ keeps working.
47
+
48
+ ## What it is not
49
+
50
+ Not a renderer and not a runtime. Everything behind the views is in
51
+ [`basalt-core`](https://www.npmjs.com/package/basalt-core): React Native's own
52
+ Hermes, Yoga, Fabric and shadow tree, the same C++ that runs on a phone. This
53
+ package is the part ReactCommon declares and leaves for a platform to define.
54
+
55
+ The host binary is `basalt_win32`, which is what shows in Task Manager and in
56
+ a crash report rather than your app's name.
57
+
58
+ ## Licence
59
+
60
+ MIT.
@@ -9,6 +9,7 @@
9
9
  #include <wrl/client.h>
10
10
 
11
11
  #include <cmath>
12
+ #include <cstdlib>
12
13
  #include <string>
13
14
 
14
15
  using Microsoft::WRL::ComPtr;
@@ -43,6 +44,77 @@ std::wstring widen(const std::string &text) {
43
44
  return wide;
44
45
  }
45
46
 
47
+ // How many device pixels to a density-independent one, from
48
+ // BASALT_SNAPSHOT_SCALE.
49
+ //
50
+ // One by default, which is what every test wants: a pixel assertion is
51
+ // written against the frames, and a snapshot that changed size with the
52
+ // machine would compare as a difference against the other two hosts.
53
+ //
54
+ // Two is for a picture somebody is going to look at. macOS and Linux are
55
+ // photographed on retina displays and in a scaled Xvfb, so a Windows shot
56
+ // beside them at 96 dpi is visibly the soft one. Anything outside [1, 4] is
57
+ // ignored rather than argued with.
58
+ float snapshotScale() {
59
+ const char *requested = std::getenv("BASALT_SNAPSHOT_SCALE");
60
+ if (requested == nullptr) {
61
+ return 1.0f;
62
+ }
63
+ const float scale = std::strtof(requested, nullptr);
64
+ if (!(scale >= 1.0f) || scale > 4.0f) {
65
+ return 1.0f;
66
+ }
67
+ return scale;
68
+ }
69
+
70
+ // One PNG writer for both the render and the photograph, so the two cannot
71
+ // drift into writing different files. `format` is what the frame is told to
72
+ // hold: premultiplied BGRA for a render, which carries transparency, and BGR
73
+ // for a window capture, where GDI leaves the alpha byte at zero and reading
74
+ // it as alpha produces a picture of nothing.
75
+ bool encodePng(IWICImagingFactory *wic,
76
+ IWICBitmapSource *source,
77
+ UINT width,
78
+ UINT height,
79
+ const std::string &path,
80
+ WICPixelFormatGUID format) {
81
+ const std::wstring widePath = widen(path);
82
+ if (widePath.empty()) {
83
+ return false;
84
+ }
85
+
86
+ ComPtr<IWICStream> stream;
87
+ if (FAILED(wic->CreateStream(stream.GetAddressOf())) ||
88
+ FAILED(stream->InitializeFromFilename(widePath.c_str(), GENERIC_WRITE))) {
89
+ return false;
90
+ }
91
+
92
+ ComPtr<IWICBitmapEncoder> encoder;
93
+ if (FAILED(wic->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.GetAddressOf())) ||
94
+ FAILED(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache))) {
95
+ return false;
96
+ }
97
+
98
+ ComPtr<IWICBitmapFrameEncode> frameEncode;
99
+ if (FAILED(encoder->CreateNewFrame(frameEncode.GetAddressOf(), nullptr)) ||
100
+ FAILED(frameEncode->Initialize(nullptr)) || FAILED(frameEncode->SetSize(width, height))) {
101
+ return false;
102
+ }
103
+
104
+ // The encoder is allowed to answer with a format it prefers; PNG takes
105
+ // these, and asking is the documented way rather than an optimisation.
106
+ if (FAILED(frameEncode->SetPixelFormat(&format))) {
107
+ return false;
108
+ }
109
+
110
+ if (FAILED(frameEncode->WriteSource(source, nullptr)) || FAILED(frameEncode->Commit()) ||
111
+ FAILED(encoder->Commit())) {
112
+ return false;
113
+ }
114
+
115
+ return true;
116
+ }
117
+
46
118
  // Renders the tree into a WIC bitmap. The one place the render is described, so
47
119
  // the pixels a test reads and the pixels a PNG carries cannot come out
48
120
  // different -- the same reason the text layout is built in one place and shared
@@ -53,8 +125,9 @@ bool renderToBitmap(const RnWin32View &root,
53
125
  UINT &width,
54
126
  UINT &height) {
55
127
  const RnRect frame = root.frame();
56
- width = static_cast<UINT>(std::lround(frame.width));
57
- height = static_cast<UINT>(std::lround(frame.height));
128
+ const float scale = snapshotScale();
129
+ width = static_cast<UINT>(std::lround(frame.width * scale));
130
+ height = static_cast<UINT>(std::lround(frame.height * scale));
58
131
  if (width == 0 || height == 0) {
59
132
  return false;
60
133
  }
@@ -80,16 +153,17 @@ bool renderToBitmap(const RnWin32View &root,
80
153
  return false;
81
154
  }
82
155
 
83
- // 96 dpi explicitly, so a render is the size the frames say regardless of
84
- // what the machine taking it is set to. Every coordinate in this project is
85
- // in React Native's density-independent pixels, and a snapshot that silently
86
- // came out at 150% on one developer's laptop would compare as a difference
87
- // against every other host.
156
+ // 96 dpi times the scale, so a render is the size the frames say regardless
157
+ // of what the machine taking it is set to. Every coordinate in this project
158
+ // is in React Native's density-independent pixels, and a snapshot that
159
+ // silently came out at 150% on one developer's laptop would compare as a
160
+ // difference against every other host. The scale is one unless somebody
161
+ // asks, so every existing caller gets exactly the pixels it got before.
88
162
  const D2D1_RENDER_TARGET_PROPERTIES properties = D2D1::RenderTargetProperties(
89
163
  D2D1_RENDER_TARGET_TYPE_DEFAULT,
90
164
  D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED),
91
- 96.0f,
92
- 96.0f);
165
+ 96.0f * scale,
166
+ 96.0f * scale);
93
167
 
94
168
  ComPtr<ID2D1RenderTarget> target;
95
169
  if (FAILED(d2d->CreateWicBitmapRenderTarget(bitmap.Get(), properties, target.GetAddressOf()))) {
@@ -159,43 +233,93 @@ bool writeSnapshot(const RnWin32View &root, const std::string &path) {
159
233
  if (!renderToBitmap(root, wic, bitmap, width, height)) {
160
234
  return false;
161
235
  }
236
+ return encodePng(wic.Get(), bitmap.Get(), width, height, path, GUID_WICPixelFormat32bppPBGRA);
237
+ }
162
238
 
163
- const std::wstring widePath = widen(path);
164
- if (widePath.empty()) {
239
+ bool captureWindow(void *windowHandle, const std::string &path) {
240
+ HWND window = static_cast<HWND>(windowHandle);
241
+ if (window == nullptr || IsWindow(window) == FALSE) {
165
242
  return false;
166
243
  }
167
244
 
168
- ComPtr<IWICStream> stream;
169
- if (FAILED(wic->CreateStream(stream.GetAddressOf())) ||
170
- FAILED(stream->InitializeFromFilename(widePath.c_str(), GENERIC_WRITE))) {
245
+ RECT rect{};
246
+ if (GetWindowRect(window, &rect) == FALSE) {
171
247
  return false;
172
248
  }
173
-
174
- ComPtr<IWICBitmapEncoder> encoder;
175
- if (FAILED(wic->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.GetAddressOf())) ||
176
- FAILED(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache))) {
249
+ const LONG width = rect.right - rect.left;
250
+ const LONG height = rect.bottom - rect.top;
251
+ if (width <= 0 || height <= 0) {
177
252
  return false;
178
253
  }
179
254
 
180
- ComPtr<IWICBitmapFrameEncode> frameEncode;
181
- if (FAILED(encoder->CreateNewFrame(frameEncode.GetAddressOf(), nullptr)) ||
182
- FAILED(frameEncode->Initialize(nullptr)) || FAILED(frameEncode->SetSize(width, height))) {
255
+ HDC screen = GetDC(nullptr);
256
+ if (screen == nullptr) {
183
257
  return false;
184
258
  }
185
-
186
- // The encoder is allowed to answer with a format it prefers; PNG takes this
187
- // one, and asking is the documented way rather than an optimisation.
188
- WICPixelFormatGUID format = GUID_WICPixelFormat32bppPBGRA;
189
- if (FAILED(frameEncode->SetPixelFormat(&format))) {
259
+ HDC memory = CreateCompatibleDC(screen);
260
+ if (memory == nullptr) {
261
+ ReleaseDC(nullptr, screen);
190
262
  return false;
191
263
  }
192
264
 
193
- if (FAILED(frameEncode->WriteSource(bitmap.Get(), nullptr)) || FAILED(frameEncode->Commit()) ||
194
- FAILED(encoder->Commit())) {
195
- return false;
265
+ // Top-down, 32 bits, so the rows come out in the order the encoder wants
266
+ // and there is no flip to get wrong. A negative height is how a DIB says
267
+ // top-down.
268
+ BITMAPINFO info{};
269
+ info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
270
+ info.bmiHeader.biWidth = width;
271
+ info.bmiHeader.biHeight = -height;
272
+ info.bmiHeader.biPlanes = 1;
273
+ info.bmiHeader.biBitCount = 32;
274
+ info.bmiHeader.biCompression = BI_RGB;
275
+
276
+ void *bits = nullptr;
277
+ HBITMAP dib = CreateDIBSection(memory, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
278
+ bool wrote = false;
279
+ if (dib != nullptr && bits != nullptr) {
280
+ HGDIOBJ previous = SelectObject(memory, dib);
281
+ // PW_RENDERFULLCONTENT (2) is the flag that asks DWM for the composited
282
+ // window rather than asking the app to paint itself, which is what gets
283
+ // Direct2D content, child windows and the caption buttons.
284
+ const BOOL printed = PrintWindow(window, memory, 2);
285
+ SelectObject(memory, previous);
286
+
287
+ if (printed != FALSE) {
288
+ ComPtr<IWICImagingFactory> wic;
289
+ if (ensureCom() &&
290
+ SUCCEEDED(CoCreateInstance(
291
+ CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&wic)))) {
292
+ const UINT stride = static_cast<UINT>(width) * 4;
293
+ const UINT size = stride * static_cast<UINT>(height);
294
+ ComPtr<IWICBitmap> bitmap;
295
+ // 32bppBGR rather than BGRA: GDI leaves the alpha byte at zero, and
296
+ // read as alpha that is a fully transparent picture of nothing.
297
+ if (SUCCEEDED(wic->CreateBitmapFromMemory(
298
+ static_cast<UINT>(width),
299
+ static_cast<UINT>(height),
300
+ GUID_WICPixelFormat32bppBGR,
301
+ stride,
302
+ size,
303
+ static_cast<BYTE *>(bits),
304
+ bitmap.GetAddressOf()))) {
305
+ wrote = encodePng(
306
+ wic.Get(),
307
+ bitmap.Get(),
308
+ static_cast<UINT>(width),
309
+ static_cast<UINT>(height),
310
+ path,
311
+ GUID_WICPixelFormat32bppBGR);
312
+ }
313
+ }
314
+ }
196
315
  }
197
316
 
198
- return true;
317
+ if (dib != nullptr) {
318
+ DeleteObject(dib);
319
+ }
320
+ DeleteDC(memory);
321
+ ReleaseDC(nullptr, screen);
322
+ return wrote;
199
323
  }
200
324
 
201
325
  } // namespace basalt::win32
@@ -63,4 +63,28 @@ RnPixels renderToPixels(const RnWin32View &root);
63
63
  // not be rendered or the file could not be written.
64
64
  bool writeSnapshot(const RnWin32View &root, const std::string &path);
65
65
 
66
+ // A photograph of the live window instead of a render of the tree, through
67
+ // `PrintWindow` with PW_RENDERFULLCONTENT, which asks DWM for what it
68
+ // composited.
69
+ //
70
+ // Everything above renders `RnWin32View`s and is the right answer for a test:
71
+ // no window has to exist, nothing has to be visible, and the result is the
72
+ // size the frames say. Two things are not in that tree, and backlog entry 3
73
+ // in docs/backlog/platform-windows.md has named both since 2026-10-13. A
74
+ // `<TextInput>`'s peer is a child window, so a field renders as its
75
+ // background with no text, no placeholder and no caret. A `<Canvas>` shows
76
+ // the last frame delivered to the live window, which an offscreen pass never
77
+ // had. The caption buttons are a third: DWM draws them over the app's header
78
+ // and no view knows about them.
79
+ //
80
+ // So this is for a picture of the application as a person sees it, and the
81
+ // render above stays what the tests read. It needs a real window, and the
82
+ // pixels are the window's own, which means the machine's DPI rather than
83
+ // BASALT_SNAPSHOT_SCALE.
84
+ //
85
+ // `window` is an HWND, as `void *` so this header stays free of windows.h.
86
+ // Returns false if there is no window, if PrintWindow refused, or if the
87
+ // file could not be written.
88
+ bool captureWindow(void *window, const std::string &path);
89
+
66
90
  } // namespace basalt::win32
@@ -865,6 +865,35 @@ void writeSnapshotOf(RnWin32View *root, const std::string &path) {
865
865
  }
866
866
  }
867
867
 
868
+ // BASALT_WINDOW_CAPTURE: photograph the live window on the way out.
869
+ //
870
+ // The snapshot below renders the view tree, which is what a test should read
871
+ // and is not what a person sees: a <TextInput>'s peer is a child window, a
872
+ // <Canvas> holds the last frame delivered to the real window, and the caption
873
+ // buttons belong to DWM. None of those are in the tree. This asks DWM for the
874
+ // window it composited instead, so a picture for a README has the field, the
875
+ // canvas and the buttons in it.
876
+ //
877
+ // Taken before teardown, like everything else here, because it needs a window
878
+ // that still exists. A failure is reported and otherwise ignored: a picture
879
+ // nobody took is not a reason for an application to exit non-zero.
880
+ void captureWindowIfRequested() {
881
+ const char *path = std::getenv("BASALT_WINDOW_CAPTURE");
882
+ if (path == nullptr) {
883
+ return;
884
+ }
885
+ HWND window = gHost.windows.empty() ? nullptr : gHost.main().window;
886
+ if (window == nullptr) {
887
+ std::fprintf(stderr, "no window to capture\n");
888
+ return;
889
+ }
890
+ if (basalt::win32::captureWindow(window, path)) {
891
+ std::fprintf(stderr, "captured the window to %s\n", path);
892
+ } else {
893
+ std::fprintf(stderr, "could not capture the window to %s\n", path);
894
+ }
895
+ }
896
+
868
897
  void snapshotIfRequested() {
869
898
  const char *path = std::getenv("BASALT_SNAPSHOT");
870
899
  if (path == nullptr) {
@@ -1295,6 +1324,7 @@ void captureBeforeTeardown() {
1295
1324
  gQuitting = true;
1296
1325
  dumpTreeIfRequested();
1297
1326
  dumpMenuIfRequested();
1327
+ captureWindowIfRequested();
1298
1328
  snapshotIfRequested();
1299
1329
  }
1300
1330
 
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "basalt-win32",
3
- "version": "0.2.7",
4
- "description": "The Windows view layer for basalt-core: a Win32 and Direct2D view layer, a Fabric mounting manager, and a host. Everything behind them is in basalt-core.",
3
+ "version": "0.2.8",
4
+ "description": "React Native on Windows, over Win32 and Direct2D: the view layer, the Fabric mounting manager and the host. The desktop half of Basalt; everything behind it is basalt-core.",
5
+ "keywords": [
6
+ "react-native",
7
+ "desktop",
8
+ "windows",
9
+ "win32",
10
+ "direct2d",
11
+ "directwrite",
12
+ "basalt",
13
+ "cross-platform"
14
+ ],
5
15
  "license": "MIT",
6
16
  "repository": {
7
17
  "type": "git",
@@ -22,6 +32,6 @@
22
32
  "react-native": "*"
23
33
  },
24
34
  "dependencies": {
25
- "basalt-core": "0.2.7"
35
+ "basalt-core": "0.2.8"
26
36
  }
27
37
  }