bun-webview2 0.3.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-14
4
+
5
+ - **The window host is now a native executable.** After the 2026-10-14 Windows security updates, WebView2 environment creation faults at address 0x0 inside `EmbeddedBrowserWebView.dll` when hosted by `bun.exe` — any thread, any loader mode — while plain native executables work (verified by differential testing: C# WinForms ✅, plain C++ statically linked ✅, plain C++ via `LoadLibrary` ✅, Bun ❌). The host (`src/host-ffi.ts`, a Bun process driving the C library via `bun:ffi`) is replaced by `native/runner.exe`: a ~250-line C++ executable compiled with `zig c++` against the vendored webview library.
6
+ - The runner owns the window, the Win32 message loop, the WndProc subclass, and the native command handler; events flow as JSON lines on stdout. `src/host-ffi.ts` and `native/webview.dll` are gone — the runner statically links the C library. `native/` now ships `runner.exe`, `WebView2Loader.dll`, and the `runner.cc` source (build command in its header).
7
+ - Vendored C library patches, required for the updated OS/runtime combo: a real `ICoreWebView2EnvironmentOptions` object (null options fault after the update; the object must also report a `TargetCompatibleBrowserVersion`, e.g. the SDK's, or the runtime rejects creation with `E_INVALIDARG`), and the standalone WebView2 runtime (`EdgeWebView\Application`) preferred over the Edge channel's stale copy when the user hasn't chosen one explicitly.
8
+ - Public API unchanged: verify 21/21. Benchmarks: cold start ~0.85–0.9 s, evaluate ~0.8 ms, IPC ~0.75 ms, navigation ~140 ms — same class as the previous architecture, and the runner process is ~4 MB instead of a ~40 MB Bun child per window.
9
+ - `icon` window option: path to an `.ico` file, applied by the runner via `WM_SETICON` + class icon + an AppUserModelID derived from the title (taskbar groups/labels your app, not "runner"). The window belongs to the runner process, so application icons must be set this way — `rcedit` on your own exe no longer reaches the window.
10
+
11
+ ## 0.3.1 — 2026-10-13
12
+
13
+ - `minWidth`/`minHeight` window options: minimum window size in client
14
+ coordinates, enforced on user drags and programmatic `setSize` alike
15
+ (via the C library's sizing hints + `WM_GETMINMAXINFO`, with the window
16
+ frame delta compensated). One-sided limits are rejected at creation.
17
+
3
18
  ## 0.3.0 — 2026-10-13
4
19
 
5
20
  - Window layer replaced: hosts now drive the [C `webview` library](https://github.com/webview/webview) through `bun:ffi` — zero npm runtime dependencies. The DLL (~540 KB) is built once with `zig c++` (no MSVC, no cmake); sources and pinned WebView2 SDK headers live in the build workspace.
package/LICENSE CHANGED
@@ -2,7 +2,7 @@ MIT License
2
2
 
3
3
  Copyright (c) 2026 bun-webview2 contributors
4
4
 
5
- The bundled native/webview.dll incorporates source code from the webview
5
+ The bundled native/runner.exe statically incorporates source code from the webview
6
6
  project (https://github.com/webview/webview), Copyright (c) 2017 Serge
7
7
  Zaitsev, Copyright (c) 2022 Steffen André Langnes, licensed under the MIT
8
8
  License. The WebView2 SDK headers used to compile it are Copyright (c)
package/README.md CHANGED
@@ -4,7 +4,7 @@ Desktop apps with Bun on Windows — native **WebView2** windows you fully contr
4
4
 
5
5
  No browser UI. No tab strip, no address bar, no Edge "App bar", no first-run nags. Your window, your icon, your process — and sub-millisecond control of the page inside it.
6
6
 
7
- Zero npm runtime dependencies: the window layer is the [C `webview` library](https://github.com/webview/webview) — a single ~540 KB DLL, built once with `zig c++` (no MSVC, no cmake) and driven directly through `bun:ffi`.
7
+ Zero npm runtime dependencies: the window layer is the [C `webview` library](https://github.com/webview/webview) compiled into a ~1 MB native runner, built once with `zig c++` (no MSVC, no cmake).
8
8
 
9
9
  ## Quick start
10
10
 
@@ -50,23 +50,23 @@ Run it with `bun run`. Close the window (or call `win.close()`) and the process
50
50
 
51
51
  ## How it works
52
52
 
53
- Each window is owned by a dedicated **host process** — a tiny Bun script shipped with the library (`src/host-ffi.ts`) that creates one native window through the C webview library via `bun:ffi` and nothing else. Your process drives the page over CDP:
53
+ Each window is owned by a dedicated **host process** — `runner.exe`, a ~250-line native executable shipped with the library that creates one window through the C webview library and nothing else. Your process drives the page over CDP:
54
54
 
55
55
  ```mermaid
56
56
  graph TB
57
- A["Your Bun app"] -- "spawns one per window" --> H1["host process #1\n(C webview lib via bun:ffi)"]
58
- A -- "spawns one per window" --> H2["host process #2\n(C webview lib via bun:ffi)"]
57
+ A["Your Bun app"] -- "spawns one per window" --> H1["runner.exe #1\n(native, owns the message loop)"]
58
+ A -- "spawns one per window" --> H2["runner.exe #2\n(native, owns the message loop)"]
59
59
  A -- "CDP over ws://127.0.0.1:<port>" --> P1["page target #1"]
60
60
  A -- "CDP over ws://127.0.0.1:<port>" --> P2["page target #2"]
61
61
  H1 -- "renders" --> P1
62
62
  H2 -- "renders" --> P2
63
63
  ```
64
64
 
65
- - **One host process per window** — buys per-window crash isolation: a dying window never takes your app down.
65
+ - **One native runner per window** — `runner.exe` (C++, ~1 MB, built with `zig c++`) owns the window and the Win32 message loop. Per-window crash isolation: a dying window never takes your app down. Native by necessity: after the 2026-10-14 Windows security updates, WebView2 environment creation faults inside `bun.exe` processes (any thread, any loader mode) while plain native executables work — verified by differential testing.
66
66
  - **Debug port** — the library passes `--remote-debugging-port` and the data folder to each host via the WebView2 loader environment variables (`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` / `WEBVIEW2_USER_DATA_FOLDER`). All windows of an app share one WebView2 browser process and one port; each window claims its own page target.
67
67
  - **Everything else is CDP.** Navigation, evaluation, IPC, and screenshots flow over a loopback WebSocket using Bun's built-in client. No native module is loaded in your process.
68
- - **Window commands ride the page.** The host's only JS runs re-entrantly inside `webview_run` — the C library's plain Win32 `GetMessage` pump, so there is no JS event loop to lose at all. Commands are CDP-evaluated as `bwv2_host_cmd(...)` in the page; the C library delivers them to the host's bind callback natively, and `webview_return` resolves the page-side promise the parent awaits — a resolved call IS the delivery confirmation. (This design came from a hard lesson: the previous wry/tao-based host's "non-blocking" pump could freeze the whole JS event loop; the C library has no loop to freeze.)
69
- - **Events are split by layer.** Window-level events (`move`, `resize`, `focus`/`blur` via `WM_ACTIVATE`, `theme-changed`) are forwarded from the host's subclassed window procedure. Page-surface events (`key-down`/`key-up`, `mouse-*`, `scroll`, `file-drop`) are captured as DOM events by an injected bridge and forwarded over the existing CDP binding — richer than Win32 could report (layout-aware `key`/`code`, readable `File` objects on drop) and working everywhere input actually lands, including cross-origin documents.
68
+ - **Window commands ride the page.** Commands are CDP-evaluated as `bwv2_host_cmd(...)` in the page; the C library delivers them to the runner's native bind callback, and `webview_return` resolves the page-side promise the parent awaits — a resolved call IS the delivery confirmation. The message loop lives in pure native code: no JS event loop exists in the window-owning process at all, so neither the wry/tao pump freeze (the original architecture's failure mode) nor the Bun-process incompatibility can occur.
69
+ - **Events are split by layer.** Window-level events (`move`, `resize`, `focus`/`blur` via `WM_ACTIVATE`, `theme-changed`) are forwarded from the runner's subclassed window procedure over stdout. Page-surface events (`key-down`/`key-up`, `mouse-*`, `scroll`, `file-drop`) are captured as DOM events by an injected bridge and forwarded over the existing CDP binding — richer than Win32 could report (layout-aware `key`/`code`, readable `File` objects on drop) and working everywhere input actually lands, including cross-origin documents.
70
70
 
71
71
  ### Why all content flows through CDP (and why windows are "primed")
72
72
 
@@ -84,6 +84,8 @@ Related, and also handled for you: on a never-navigated webview target, CDP comm
84
84
  |---|---|---|---|
85
85
  | `title` | `string` | `"bun-webview2"` | Window title |
86
86
  | `width` / `height` | `number` | `800` / `600` | Initial window size |
87
+ | `minWidth` / `minHeight` | `number` | — | Minimum window size (client coordinates); enforced on drags and `setSize` alike. Both must be set together. |
88
+ | `icon` | `string` | — | Path to an `.ico` file for the window/taskbar icon. The runner (which owns the window) applies it along with an AppUserModelID derived from the title, so taskbar entries group and label as your app. |
87
89
  | `url` | `string` | — | Initial URL to load |
88
90
  | `html` | `string` | — | Initial inline HTML (loaded as a `data:` URL) |
89
91
  | `contextMenu` | `boolean` | `false` | Allow the native Chromium right-click menu (blocked by default) |
@@ -156,19 +158,14 @@ route "new window" to the running instance instead of spawning again.
156
158
 
157
159
  ## Distribution
158
160
 
159
- - Each window spawns a host process. In development that's `bun` running the library's `src/host.ts`; your machine needs Bun on the PATH-compatible `process.execPath`.
160
- - For `bun build --compile` executables, compile the host separately and point the `BWV2_HOST_EXE` environment variable at it:
161
- ```bash
162
- bun build --compile --minify src/host-ffi.ts --outfile host.exe # inside node_modules: copy out first
163
- BWV2_HOST_EXE=./host.exe ./your-app.exe
164
- ```
165
- - `native/webview.dll` (and the host script, unless compiled) must ship **alongside** your binary — `bun build --compile` embeds neither.
166
- - Give the exe a custom icon with [rcedit](https://github.com/electron/rcedit); the window inherits the process icon.
161
+ - Each window spawns `runner.exe` from the package's `native/` directory. End-user machines need only the WebView2 runtime (preinstalled on Windows 11 and current Windows 10).
162
+ - `native/runner.exe` and `native/WebView2Loader.dll` must ship **alongside** your binary — `bun build --compile` embeds neither. For custom builds, compile `native/runner.cc` yourself (the build command is in its header comment) and point `BWV2_HOST_EXE` at it.
163
+ - Give the app a custom icon with the `icon` option — the window belongs to the runner process, so `rcedit`-ing your own exe does not reach it.
167
164
  - WebView2 writes its user-data folder per app; if you need to control where (e.g. Program Files installs), patch `userDataFolder` at the window layer.
168
165
 
169
166
  ## Costs, honestly
170
167
 
171
- - Every window costs one Bun process (~40 MB) plus its share of the shared WebView2 browser tree. For 1–3 windows this is desktop-app normal.
168
+ - Every window costs one native runner process (~4 MB) plus its share of the shared WebView2 browser tree. For 1–3 windows this is desktop-app normal.
172
169
  - One WebView2 browser process per app means one app instance at a time (see the data-folder section above).
173
170
  - The CDP debug port is a local-loopback control channel: fine for desktop apps, not for hostile multi-user machines (see security notes).
174
171
 
Binary file
@@ -0,0 +1,349 @@
1
+ // bun-webview2 native runner — owns one window + webview + message loop.
2
+ //
3
+ // This is the process that MUST NOT be a Bun process: after the 2026-10-14
4
+ // Windows security updates, WebView2 environment creation faults at address
5
+ // 0x0 inside EmbeddedBrowserWebView.dll when hosted by bun.exe (any thread,
6
+ // any loader mode), while plain native executables work. The runner is a
7
+ // plain C++ executable; the parent Bun process keeps full control of the
8
+ // page over CDP and receives window events as JSON lines on stdout.
9
+ //
10
+ // Protocol (identical to the TypeScript host it replaces):
11
+ // argv[last] = JSON config {title, width, height, minWidth?, minHeight?}
12
+ // stdout = one JSON object per line: window events + {"event":"closed"}
13
+ // page -> runner = bwv2_host_cmd("<command json>") via webview_bind;
14
+ // reply flows through webview_return, so the page-side
15
+ // promise the parent awaits resolves = command applied
16
+ //
17
+ // Build (zig c++, no MSVC, no cmake):
18
+ // zig c++ -std=c++17 -O2 -DWEBVIEW_MSWEBVIEW2_BUILTIN_IMPL=0 \
19
+ // -I <webview-src>/core/include -I <webview-src>/compatibility/mingw/include \
20
+ // -I <webview2-sdk>/build/native/include \
21
+ // native/runner.cc <webview-src>/core/src/webview.cc \
22
+ // <webview2-sdk>/build/native/x64/WebView2Loader.dll.lib \
23
+ // -ladvapi32 -lole32 -lshell32 -lshlwapi -luser32 -lversion \
24
+ // -o native/runner.exe
25
+
26
+ #include "webview/webview.h"
27
+ #include "webview/detail/json.hh"
28
+
29
+ #include <windows.h>
30
+ #include <shellapi.h>
31
+ #include <propsys.h>
32
+ #include <shobjidl.h>
33
+ #include <propkey.h>
34
+ #include <shlwapi.h>
35
+ #include <cstdio>
36
+ #include <cstring>
37
+ #include <filesystem>
38
+ #include <string>
39
+
40
+ namespace {
41
+
42
+ // --- window events via WndProc subclassing ---------------------------------
43
+ // Only non-client, window-level messages reach this procedure: input over the
44
+ // page surface goes to the WebView2 child HWND and is delivered to apps as
45
+ // page DOM events by the parent's injected bridge.
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+ void emit(const std::string &line) {
68
+ std::fputs(line.c_str(), stdout);
69
+ std::fputc('\n', stdout);
70
+ std::fflush(stdout);
71
+ }
72
+
73
+ void emit_event(const std::string &name, const std::string &fields = "") {
74
+ emit("{\"event\":\"" + name + "\"" + (fields.empty() ? "" : "," + fields) + "}");
75
+ }
76
+
77
+ std::string number(double v) {
78
+ auto as_long = static_cast<long long>(v);
79
+ if (v == static_cast<double>(as_long)) {
80
+ return std::to_string(as_long);
81
+ }
82
+ return std::to_string(v);
83
+ }
84
+
85
+ webview_t g_handle = nullptr;
86
+ HWND g_hwnd = nullptr;
87
+ WNDPROC g_original_proc = nullptr;
88
+
89
+ std::string theme_text() {
90
+ DWORD value = 0;
91
+ DWORD size = sizeof(value);
92
+ if (RegGetValueW(HKEY_CURRENT_USER,
93
+ L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
94
+ L"AppsUseLightTheme", RRF_RT_REG_DWORD, nullptr, &value, &size) == 0 &&
95
+ value == 1) {
96
+ return "light";
97
+ }
98
+ return "dark";
99
+ }
100
+
101
+ LRESULT CALLBACK runner_wndproc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
102
+ switch (msg) {
103
+ case WM_MOVE:
104
+ emit_event("move",
105
+ "\"x\":" + std::to_string(static_cast<short>(LOWORD(lp))) +
106
+ ",\"y\":" + std::to_string(static_cast<short>(HIWORD(lp))));
107
+ break;
108
+ case WM_SIZE:
109
+ emit_event("resize",
110
+ "\"width\":" + std::to_string(LOWORD(lp)) +
111
+ ",\"height\":" + std::to_string(HIWORD(lp)));
112
+ break;
113
+ case WM_ACTIVATE:
114
+ // window activation: clicks on the page activate the top window too,
115
+ // unlike WM_SETFOCUS which the WebView2 child HWND claims
116
+ emit_event(LOWORD(wp) != 0 ? "focus" : "blur");
117
+ break;
118
+ case WM_THEMECHANGED:
119
+ emit_event("theme-changed", "\"text\":\"" + theme_text() + "\"");
120
+ break;
121
+ default:
122
+ break;
123
+ }
124
+ if (g_original_proc != nullptr) {
125
+ return CallWindowProcW(g_original_proc, hwnd, msg, wp, lp);
126
+ }
127
+ return DefWindowProcW(hwnd, msg, wp, lp);
128
+ }
129
+
130
+ // --- command channel: page -> webview_bind -> here ------------------------
131
+
132
+ std::string apply_command(const std::string &op, const std::string &args_json) {
133
+ const auto arg = [&args_json](int index) {
134
+ return webview::detail::json_parse(args_json, "", index);
135
+ };
136
+ if (op == "hello") {
137
+ return "true";
138
+ }
139
+ if (op == "setTitle") {
140
+ webview_set_title(g_handle, arg(0).c_str());
141
+ return "0";
142
+ }
143
+ if (op == "setSize") {
144
+ webview_set_size(g_handle, std::atoi(arg(0).c_str()), std::atoi(arg(1).c_str()),
145
+ WEBVIEW_HINT_NONE);
146
+ return "0";
147
+ }
148
+ if (op == "setPosition") {
149
+ SetWindowPos(g_hwnd, nullptr, std::atoi(arg(0).c_str()), std::atoi(arg(1).c_str()), 0, 0,
150
+ SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
151
+ return "0";
152
+ }
153
+ if (op == "setDecorations") {
154
+ auto current = GetWindowLongPtrW(g_hwnd, GWL_STYLE);
155
+ auto masked = current & ~WS_OVERLAPPEDWINDOW;
156
+ auto updated = arg(0) == "true" ? masked | WS_OVERLAPPEDWINDOW : masked;
157
+ SetWindowLongPtrW(g_hwnd, GWL_STYLE, updated);
158
+ SetWindowPos(g_hwnd, nullptr, 0, 0, 0, 0,
159
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
160
+ return "0";
161
+ }
162
+ if (op == "setAlwaysOnTop") {
163
+ SetWindowPos(g_hwnd, arg(0) == "true" ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0,
164
+ SWP_NOMOVE | SWP_NOSIZE);
165
+ return "0";
166
+ }
167
+ if (op == "focus") {
168
+ return std::to_string(SetForegroundWindow(g_hwnd));
169
+ }
170
+ if (op == "minimize") {
171
+ return std::to_string(ShowWindow(g_hwnd, SW_MINIMIZE));
172
+ }
173
+ if (op == "maximize") {
174
+ return std::to_string(ShowWindow(g_hwnd, SW_MAXIMIZE));
175
+ }
176
+ if (op == "close") {
177
+ // restore the original procedure before tearing the window down
178
+ if (g_original_proc != nullptr) {
179
+ SetWindowLongPtrW(g_hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(g_original_proc));
180
+ g_original_proc = nullptr;
181
+ }
182
+ webview_terminate(g_handle);
183
+ return "true";
184
+ }
185
+ return "null";
186
+ }
187
+
188
+ void on_command(const char *id, const char *req, void * /*arg*/) {
189
+ // req is a JSON array of the call arguments; element 0 is our command payload
190
+ auto payload = webview::detail::json_parse(req, "", 0);
191
+ const auto op = webview::detail::json_parse(payload, "op", 0);
192
+ const auto args = webview::detail::json_parse(payload, "args", 0);
193
+ std::string result = "null";
194
+ try {
195
+ result = apply_command(op, args);
196
+ } catch (const std::exception &e) {
197
+ std::fprintf(stderr, "[runner] command '%s' failed: %s\n", op.c_str(), e.what());
198
+ }
199
+ webview_return(g_handle, id, 0, result.c_str());
200
+ }
201
+
202
+ // --- app icon and taskbar identity -----------------------------------------
203
+ // The runner owns the HWND, so the app's icon must be applied here: the C
204
+ // library sets a generic stock icon on its window class. An AppUserModelID
205
+ // makes Windows group and label the window as the app, not as "runner".
206
+
207
+ bool apply_icon(const std::string &icon_path, const std::string &app_name) {
208
+ if (icon_path.empty()) {
209
+ return false;
210
+ }
211
+ std::wstring wide_path(icon_path.size(), L'\0');
212
+ auto path_len = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, icon_path.c_str(),
213
+ static_cast<int>(icon_path.size()), wide_path.data(),
214
+ static_cast<int>(icon_path.size()));
215
+ if (path_len <= 0) {
216
+ return false;
217
+ }
218
+ wide_path.resize(path_len);
219
+ auto big = LoadImageW(nullptr, wide_path.c_str(), IMAGE_ICON, GetSystemMetrics(SM_CXICON),
220
+ GetSystemMetrics(SM_CYICON), LR_LOADFROMFILE);
221
+ auto small = LoadImageW(nullptr, wide_path.c_str(), IMAGE_ICON, 16, 16, LR_LOADFROMFILE);
222
+ if (big == nullptr && small == nullptr) {
223
+ std::fprintf(stderr, "[runner] icon load failed: %s (GetLastError=%lu)\n",
224
+ icon_path.c_str(), GetLastError());
225
+ return false;
226
+ }
227
+ if (big != nullptr) {
228
+ SendMessageW(g_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(big));
229
+ }
230
+ if (small != nullptr) {
231
+ SendMessageW(g_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(small));
232
+ }
233
+ // update the class icon so future windows of the same class inherit it
234
+ if (big != nullptr) {
235
+ SetClassLongPtrW(g_hwnd, GCLP_HICON, reinterpret_cast<LONG_PTR>(static_cast<HICON>(big)));
236
+ }
237
+
238
+ // AppUserModelID: taskbar groups by this, so set it per app (falls back to
239
+ // the runner's identity when absent)
240
+ if (!app_name.empty()) {
241
+ std::wstring aumid(L"bun-webview2.");
242
+ aumid += std::wstring(app_name.begin(), app_name.end());
243
+ for (auto &c : aumid) {
244
+ if (c == L' ' || c == L'.' || c == L'\\' || c == L'/') {
245
+ c = L'_';
246
+ }
247
+ }
248
+ // the shell reads AUMID from the property store — the only mechanism
249
+ // that matters; SetPropW would be a no-op that leaks a global atom
250
+ IPropertyStore *store = nullptr;
251
+ auto hwnd_ptr = static_cast<HWND>(g_hwnd);
252
+ if (SUCCEEDED(SHGetPropertyStoreForWindow(hwnd_ptr, IID_IPropertyStore,
253
+ reinterpret_cast<void **>(&store)))) {
254
+ PROPVARIANT variant;
255
+ PropVariantInit(&variant);
256
+ variant.vt = VT_LPWSTR;
257
+ variant.pwszVal = const_cast<LPWSTR>(aumid.c_str());
258
+ store->SetValue(PKEY_AppUserModel_ID, variant);
259
+ store->Release();
260
+ }
261
+ }
262
+ return true;
263
+ }
264
+
265
+ // --- standalone runtime resolution -----------------------------------------
266
+ // Windows Update services the standalone WebView2 runtime
267
+ // (EdgeWebView\Application) while the Edge channel's copy can go stale and
268
+ // crash on environment creation. Prefer the standalone tree when present.
269
+
270
+ void resolve_standalone_runtime() {
271
+ if (GetEnvironmentVariableW(L"WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", nullptr, 0) != 0) {
272
+ return; // explicit user choice
273
+ }
274
+ const std::wstring root = L"C:\\Program Files (x86)\\Microsoft\\EdgeWebView\\Application";
275
+ std::error_code ec;
276
+ if (!std::filesystem::is_directory(root, ec)) {
277
+ return;
278
+ }
279
+ std::wstring newest;
280
+ for (const auto &entry : std::filesystem::directory_iterator(root, ec)) {
281
+ if (!entry.is_directory()) {
282
+ continue;
283
+ }
284
+ const auto dir = entry.path().filename().wstring();
285
+ if (dir.find_first_not_of(L"0123456789.") != std::wstring::npos) {
286
+ continue; // version-shaped names only
287
+ }
288
+ if (!std::filesystem::exists(entry.path() / L"msedgewebview2.exe", ec)) {
289
+ continue;
290
+ }
291
+ if (newest.empty() || dir > newest) {
292
+ newest = dir;
293
+ }
294
+ }
295
+ if (!newest.empty()) {
296
+ const auto folder = root + L"\\" + newest;
297
+ SetEnvironmentVariableW(L"WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", folder.c_str());
298
+ }
299
+ }
300
+
301
+ } // namespace
302
+
303
+ int main(int argc, char **argv) {
304
+ const std::string config =
305
+ argc > 1 ? argv[argc - 1] : "{}";
306
+ const auto title = webview::detail::json_parse(config, "title", 0);
307
+ const auto width = webview::detail::json_parse(config, "width", 0);
308
+ const auto height = webview::detail::json_parse(config, "height", 0);
309
+ const auto min_width = webview::detail::json_parse(config, "minWidth", 0);
310
+ const auto min_height = webview::detail::json_parse(config, "minHeight", 0);
311
+ const auto icon_path = webview::detail::json_parse(config, "icon", 0);
312
+
313
+ resolve_standalone_runtime();
314
+
315
+ g_handle = webview_create(0, nullptr);
316
+ if (g_handle == nullptr) {
317
+ std::fprintf(stderr, "[runner] webview_create failed\n");
318
+ return 1;
319
+ }
320
+ g_hwnd = static_cast<HWND>(webview_get_window(g_handle));
321
+ g_original_proc = reinterpret_cast<WNDPROC>(SetWindowLongPtrW(
322
+ g_hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(runner_wndproc)));
323
+
324
+ webview_set_title(g_handle,
325
+ (title.empty() ? "bun-webview2" : title).c_str());
326
+ webview_set_size(g_handle, width.empty() ? 800 : std::atoi(width.c_str()),
327
+ height.empty() ? 600 : std::atoi(height.c_str()), WEBVIEW_HINT_NONE);
328
+ if (!min_width.empty() && !min_height.empty()) {
329
+ // the track-size minimum is in OUTER coordinates; compensate the frame
330
+ // so the minimum applies to the client area
331
+ RECT outer{};
332
+ RECT inner{};
333
+ GetWindowRect(g_hwnd, &outer);
334
+ GetClientRect(g_hwnd, &inner);
335
+ const auto frame_w = (outer.right - outer.left) - (inner.right - inner.left);
336
+ const auto frame_h = (outer.bottom - outer.top) - (inner.bottom - inner.top);
337
+ webview_set_size(g_handle, std::atoi(min_width.c_str()) + frame_w,
338
+ std::atoi(min_height.c_str()) + frame_h, WEBVIEW_HINT_MIN);
339
+ }
340
+ webview_bind(g_handle, "bwv2_host_cmd", on_command, nullptr);
341
+ apply_icon(icon_path, title.empty() ? "app" : title);
342
+ // inert initial document; the parent drives all real content over CDP
343
+ webview_navigate(g_handle, "about:blank");
344
+
345
+ webview_run(g_handle);
346
+ // run() returning means the window closed (user close or the "close" op)
347
+ emit_event("closed");
348
+ return 0;
349
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-webview2",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "devDependencies": {
package/src/window.ts CHANGED
@@ -29,6 +29,12 @@ export interface Webview2WindowOptions extends HardeningOptions {
29
29
  readonly title?: string;
30
30
  readonly width?: number;
31
31
  readonly height?: number;
32
+ /** Minimum window size. Both must be set together — one-sided limits are rejected. */
33
+ readonly minWidth?: number;
34
+ /** @see minWidth */
35
+ readonly minHeight?: number;
36
+ /** Path to an .ico file shown as the window/taskbar icon. The runner owns the HWND, so it applies it there. */
37
+ readonly icon?: string;
32
38
  /** Initial URL to load. */
33
39
  readonly url?: string;
34
40
  /** Initial inline HTML to load (delivered as a data: URL). */
@@ -123,20 +129,31 @@ class WindowHost {
123
129
  readonly #closedWaiters: Array<() => void> = [];
124
130
  readonly #eventWaiters = new Set<(event: string, payload: unknown) => void>();
125
131
 
126
- constructor(readonly options: { title: string; width: number; height: number }) {
132
+ constructor(
133
+ readonly options: {
134
+ title: string;
135
+ width: number;
136
+ height: number;
137
+ minWidth?: number;
138
+ minHeight?: number;
139
+ icon?: string;
140
+ },
141
+ ) {
127
142
  const config = JSON.stringify(options);
128
- // Compiled executables re-run themselves instead of acting as a runtime,
129
- // so shipped apps point BWV2_HOST_EXE at a pre-compiled host binary.
130
- // host-ffi.ts drives the C webview library directly via bun:ffi.
143
+ // The runner is a plain native executable: after the 2026-10-14 Windows
144
+ // security updates, WebView2 environment creation faults inside bun.exe
145
+ // processes (any thread, any loader mode) while native executables work.
146
+ // Shipped apps can point BWV2_HOST_EXE at their own runner build.
131
147
  const command = Bun.env.BWV2_HOST_EXE
132
148
  ? [Bun.env.BWV2_HOST_EXE]
133
- : [process.execPath, `${import.meta.dir}/host-ffi.ts`];
149
+ : [`${import.meta.dir}/../native/runner.exe`];
134
150
  this.#child = Bun.spawn([...command, config], {
135
151
  stdin: "ignore",
136
152
  stdout: "pipe",
137
153
  stderr: "inherit",
138
154
  env: {
139
155
  ...Bun.env,
156
+ // runner.exe resolves WebView2Loader.dll from its own directory
140
157
  WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${activePort}`,
141
158
  WEBVIEW2_USER_DATA_FOLDER: activeDataDir,
142
159
  },
@@ -262,11 +279,17 @@ export class Webview2Window {
262
279
  `bun-webview2: dataDir ${opts.dataDir} requested, but this app already uses ${activeDataDir}. All windows share one data folder.`,
263
280
  );
264
281
  }
282
+ if ((opts.minWidth !== undefined) !== (opts.minHeight !== undefined)) {
283
+ throw new Error("bun-webview2: minWidth and minHeight must be set together — one-sided limits are not supported.");
284
+ }
265
285
 
266
286
  const host = new WindowHost({
267
287
  title: opts.title ?? "bun-webview2",
268
288
  width: opts.width ?? 800,
269
289
  height: opts.height ?? 600,
290
+ minWidth: opts.minWidth,
291
+ minHeight: opts.minHeight,
292
+ icon: opts.icon,
270
293
  });
271
294
 
272
295
  const target: CdpTargetInfo = await waitForPageTarget(activePort, claimedTargets, 15_000, activeDataDir);
Binary file
package/src/host-ffi.ts DELETED
@@ -1,244 +0,0 @@
1
- /**
2
- * Child host process (FFI flavor): owns exactly one native window + webview
3
- * driven directly through the C webview library via bun:ffi — no wry, no
4
- * tao, no napi addons.
5
- *
6
- * Command channel: the parent CDP-evaluates bwv2_host_cmd("<json>") in the
7
- * page; the C library delivers it to the bind callback re-entrantly on the
8
- * GUI thread (the only JS that runs here — webview_run blocks the JS loop by
9
- * design, and this host deliberately has no other JS machinery to lose to a
10
- * wedged loop). Replies flow back synchronously: webview_return resolves the
11
- * page-side promise the parent awaits, so a resolved call IS the ack.
12
- *
13
- * Lifecycle: user close button or the "close" command ends webview_run; we
14
- * emit "closed" on stdout and exit. Stdout JSON lines are the only
15
- * host→parent channel needed.
16
- */
17
-
18
- import { dlopen, FFIType, JSCallback, ptr } from "bun:ffi";
19
-
20
- interface HostConfig {
21
- readonly title: string;
22
- readonly width: number;
23
- readonly height: number;
24
- }
25
-
26
- interface HostCommand {
27
- readonly op: string;
28
- readonly args?: unknown[];
29
- }
30
-
31
- // Config is always the last argv element: [bun, host-ffi.ts, config].
32
- const config = JSON.parse(process.argv[process.argv.length - 1] ?? "{}") as Partial<HostConfig>;
33
-
34
- const dllPath = Bun.env.BWV2_NATIVE_LIB ?? `${import.meta.dir}/../native/webview.dll`;
35
-
36
- const webview = dlopen(dllPath, {
37
- webview_create: { args: [FFIType.int, FFIType.pointer], returns: FFIType.pointer },
38
- webview_run: { args: [FFIType.pointer], returns: FFIType.int },
39
- webview_terminate: { args: [FFIType.pointer], returns: FFIType.int },
40
- webview_navigate: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
41
- webview_set_title: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
42
- webview_set_size: { args: [FFIType.pointer, FFIType.int, FFIType.int, FFIType.int], returns: FFIType.int },
43
- webview_get_window: { args: [FFIType.pointer], returns: FFIType.pointer },
44
- webview_bind: { args: [FFIType.pointer, FFIType.pointer, FFIType.pointer, FFIType.pointer], returns: FFIType.int },
45
- webview_return: { args: [FFIType.pointer, FFIType.pointer, FFIType.int, FFIType.pointer], returns: FFIType.int },
46
- });
47
-
48
- // Win32 window controls the C API doesn't cover.
49
- const user32 = dlopen("user32.dll", {
50
- SetWindowPos: { args: [FFIType.pointer, FFIType.i64, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.u32], returns: FFIType.int },
51
- GetWindowLongPtrW: { args: [FFIType.pointer, FFIType.i32], returns: FFIType.i64 },
52
- SetWindowLongPtrW: { args: [FFIType.pointer, FFIType.i32, FFIType.i64], returns: FFIType.i64 },
53
- GetWindowRect: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
54
- GetClientRect: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
55
- ShowWindow: { args: [FFIType.pointer, FFIType.i32], returns: FFIType.int },
56
- SetForegroundWindow: { args: [FFIType.pointer], returns: FFIType.int },
57
- CallWindowProcW: { args: [FFIType.pointer, FFIType.pointer, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.i64 },
58
- TrackMouseEvent: { args: [FFIType.pointer], returns: FFIType.int },
59
- GetKeyState: { args: [FFIType.i32], returns: FFIType.i16 },
60
- });
61
- const advapi32 = dlopen("advapi32.dll", {
62
- RegGetValueW: { args: [FFIType.pointer, FFIType.pointer, FFIType.pointer, FFIType.u32, FFIType.u32, FFIType.pointer, FFIType.pointer], returns: FFIType.i32 },
63
- });
64
-
65
- const cstr = (s: string) => ptr(Buffer.from(`${s}\0`, "utf8"));
66
-
67
- const WEBVIEW_HINT_NONE = 0;
68
- const SWP_NOSIZE = 0x0001;
69
- const SWP_NOMOVE = 0x0002;
70
- const SWP_NOZORDER = 0x0004;
71
- const SWP_NOACTIVATE = 0x0010;
72
- const SWP_FRAMECHANGED = 0x0020;
73
- const GWL_STYLE = -16;
74
- const WS_OVERLAPPEDWINDOW = 0x00cf0000;
75
- const SW_MINIMIZE = 6;
76
- const SW_MAXIMIZE = 3;
77
- const HWND_TOPMOST = -1;
78
- const HWND_NOTOPMOST = -2;
79
-
80
- if (Bun.env.BWV2_DEBUG === "1") console.error("[host-ffi] started");
81
-
82
- const handle = webview.symbols.webview_create(0, null);
83
- if (handle === 0n || handle === 0) {
84
- console.error("[host-ffi] webview_create failed");
85
- process.exit(1);
86
- }
87
- const hwnd = webview.symbols.webview_get_window(handle);
88
-
89
- const emit = (payload: Record<string, unknown>): void => {
90
- console.log(JSON.stringify(payload));
91
- };
92
-
93
- // --- Window events via WndProc subclassing -------------------------------
94
- // The flat C API has no event subscriptions, so the window's own procedure
95
- // is replaced: interesting messages become stdout events (the parent's
96
- // generic routing picks them up); everything chains to the original proc.
97
-
98
- const GWL_WNDPROC = -4;
99
- const WM_MOVE = 0x0003;
100
- const WM_SIZE = 0x0005;
101
- // Only non-client, window-level messages reach this procedure: input over
102
- // the page surface goes to the WebView2 child HWND and is delivered to apps
103
- // as page DOM events instead (src/inject.ts page-event bridge). Keys,
104
- // mouse, wheel, and file drops therefore have no WndProc cases at all.
105
- const WM_ACTIVATE = 0x0006;
106
- const WM_THEMECHANGED = 0x031a;
107
-
108
- // personalization: 1 = apps light theme, 0 = dark
109
- const themeText = (): "light" | "dark" => {
110
- const value = Buffer.alloc(4);
111
- const size = Buffer.alloc(4);
112
- size.writeUInt32LE(4, 0);
113
- const subkey = Buffer.from("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\0", "utf16le");
114
- const valueName = Buffer.from("AppsUseLightTheme\0", "utf16le");
115
- const ok = advapi32.symbols.RegGetValueW(0x80000001n /* HKEY_CURRENT_USER */, ptr(subkey), ptr(valueName), 0x0002 /* RRF_RT_REG_DWORD */, 0, ptr(value), ptr(size));
116
- return ok === 0 && value.readUInt32LE(0) === 1 ? "light" : "dark";
117
- };
118
-
119
- const originalProc: { handle: bigint | undefined } = { handle: undefined };
120
-
121
- const wndProc = new JSCallback(
122
- (hwndArg: number | bigint, msg: number | bigint, wParam: number | bigint, lParam: number | bigint) => {
123
- // FFI delivers u64/i64 as number or bigint; normalize once at the boundary
124
- const hwnd = BigInt(hwndArg);
125
- const msgN = Number(msg);
126
- const wp = BigInt.asUintN(64, BigInt(wParam));
127
- const lp = BigInt.asIntN(64, BigInt(lParam));
128
- switch (msgN) {
129
- case WM_MOVE: {
130
- const l = BigInt.asUintN(32, lp);
131
- emit({ event: "move", x: Number(BigInt.asIntN(16, l & 0xffffn)), y: Number(BigInt.asIntN(16, (l >> 16n) & 0xffffn)) });
132
- break;
133
- }
134
- case WM_SIZE: {
135
- const l = BigInt.asUintN(32, lp);
136
- emit({ event: "resize", width: Number(l & 0xffffn), height: Number((l >> 16n) & 0xffffn) });
137
- break;
138
- }
139
- case WM_ACTIVATE: {
140
- // window activation: clicks on the page activate the top window too,
141
- // unlike WM_SETFOCUS which the WebView2 child HWND claims.
142
- const active = (BigInt.asUintN(16, wp) & 0xffffn) !== 0n;
143
- emit({ event: active ? "focus" : "blur" });
144
- break;
145
- }
146
- case WM_THEMECHANGED:
147
- emit({ event: "theme-changed", text: themeText() });
148
- break;
149
- }
150
- // chain everything (handled or not) to the original procedure
151
- if (originalProc.handle !== undefined) {
152
- return user32.symbols.CallWindowProcW(originalProc.handle, hwnd, msgN, wp, lp);
153
- }
154
- return 0n;
155
- },
156
- { args: [FFIType.pointer, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.i64 },
157
- );
158
-
159
- // Subclass the window so the parent receives native window events.
160
- originalProc.handle = user32.symbols.SetWindowLongPtrW(hwnd, GWL_WNDPROC, BigInt(wndProc.ptr ?? 0));
161
-
162
- function applyCommand(command: HostCommand): string {
163
- const args = command.args ?? [];
164
- switch (command.op) {
165
- case "hello":
166
- return "true";
167
- case "setTitle":
168
- return String(webview.symbols.webview_set_title(handle, cstr(String(args[0]))));
169
- case "setSize":
170
- return String(webview.symbols.webview_set_size(handle, Number(args[0]), Number(args[1]), WEBVIEW_HINT_NONE));
171
- case "setPosition": {
172
- const result = user32.symbols.SetWindowPos(
173
- hwnd,
174
- 0n,
175
- Number(args[0]),
176
- Number(args[1]),
177
- 0,
178
- 0,
179
- SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
180
- );
181
- return String(result);
182
- }
183
- case "setDecorations": {
184
- const current = user32.symbols.GetWindowLongPtrW(hwnd, GWL_STYLE);
185
- const decorationsOn = Boolean(args[0]);
186
- const masked = BigInt(current) & ~BigInt(WS_OVERLAPPEDWINDOW);
187
- const updated = decorationsOn ? masked | BigInt(WS_OVERLAPPEDWINDOW) : masked;
188
- const result = user32.symbols.SetWindowLongPtrW(hwnd, GWL_STYLE, updated);
189
- user32.symbols.SetWindowPos(hwnd, 0n, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
190
- return String(result);
191
- }
192
- case "setAlwaysOnTop": {
193
- const insertAfter = args[0] ? BigInt(HWND_TOPMOST) : BigInt(HWND_NOTOPMOST);
194
- const result = user32.symbols.SetWindowPos(hwnd, insertAfter, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
195
- return String(result);
196
- }
197
- case "focus":
198
- return String(user32.symbols.SetForegroundWindow(hwnd));
199
- case "minimize":
200
- return String(user32.symbols.ShowWindow(hwnd, SW_MINIMIZE));
201
- case "maximize":
202
- return String(user32.symbols.ShowWindow(hwnd, SW_MAXIMIZE));
203
-
204
- case "close":
205
- // restore the original procedure before tearing the window down
206
- if (originalProc.handle !== undefined) {
207
- user32.symbols.SetWindowLongPtrW(hwnd, GWL_WNDPROC, BigInt(originalProc.handle));
208
- originalProc.handle = undefined;
209
- }
210
- webview.symbols.webview_terminate(handle);
211
- return "true";
212
- default:
213
- return "null";
214
- }
215
- }
216
-
217
- const bindCallback = new JSCallback(
218
- (id: string, req: string) => {
219
- if (Bun.env.BWV2_DEBUG === "1") console.error(`[host-ffi] op: ${req.slice(0, 80)}`);
220
- let result = "null";
221
- try {
222
- const parsed = JSON.parse(req) as unknown[];
223
- const payload = typeof parsed[0] === "string" ? (JSON.parse(parsed[0]) as HostCommand) : (parsed[0] as HostCommand);
224
- result = applyCommand(payload);
225
- } catch (err) {
226
- console.error("[host-ffi] command failed:", err);
227
- }
228
- webview.symbols.webview_return(handle, cstr(id), 0, cstr(result));
229
- },
230
- { args: [FFIType.cstring, FFIType.cstring, FFIType.pointer], returns: FFIType.void },
231
- );
232
-
233
- webview.symbols.webview_set_title(handle, cstr(config.title ?? "bun-webview2"));
234
- webview.symbols.webview_set_size(handle, config.width ?? 800, config.height ?? 600, WEBVIEW_HINT_NONE);
235
- webview.symbols.webview_bind(handle, cstr("bwv2_host_cmd"), bindCallback.ptr, null);
236
-
237
- // about:blank keeps the initial document inert; the parent drives all real
238
- // content over CDP.
239
- webview.symbols.webview_navigate(handle, cstr("about:blank"));
240
-
241
- webview.symbols.webview_run(handle);
242
- // run() returning means the window closed (user X or the "close" command).
243
- emit({ event: "closed" });
244
- process.exit(0);