basalt-win32 0.2.7 → 0.2.9
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 +60 -0
- package/native/win32/Win32Snapshot.cpp +154 -30
- package/native/win32/Win32Snapshot.h +24 -0
- package/native/win32/main_win32.cpp +30 -0
- package/package.json +13 -3
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
|
-
|
|
57
|
-
|
|
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
|
|
84
|
-
// what the machine taking it is set to. Every coordinate in this project
|
|
85
|
-
// in React Native's density-independent pixels, and a snapshot that
|
|
86
|
-
// came out at 150% on one developer's laptop would compare as a
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
if (
|
|
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
|
-
|
|
175
|
-
if (
|
|
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
|
-
|
|
181
|
-
if (
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.9",
|
|
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.
|
|
35
|
+
"basalt-core": "0.2.9"
|
|
26
36
|
}
|
|
27
37
|
}
|