Refs #335.
What is certain
On Windows, CreateCoreWebView2EnvironmentWithOptions returns
CO_E_NOTINITIALIZED (0x800401f0) for the desktop bundle I am building, and
the process exits before a window exists:
⚡ Loading URL in native window: http://127.0.0.1:63854/app
[WebView2] Environment creation failed: 0x800401f0
[WebView2] Initialization failed
[2026-10-12T13:04:02Z] ERROR WebView2InitFailedThat error means exactly one thing: the calling thread was in no COM
apartment. And craft never creates one — ole32 is linked for CoTaskMemFree
alone, with no CoInitializeEx, CoInitialize or OleInitialize anywhere in
the tree, in v0.0.100, v0.0.140 or today's main.
So craft currently depends on something else in the process having put the thread in an apartment. This PR removes that dependency.
What I got wrong in the first version of this description
I wrote that the window therefore never opens on Windows. That is too strong,
and your own CI disproves it: windows-multi-window passes on main, and
scripts/verify-desktop-multi-window.ts is a real test of this path — it
spawns craft.exe --url …, fails if craft exits early, and only passes when
JS running inside the webview reports its steps back. So in that job the
environment is created successfully by an unpatched build.
I checked the obvious explanation and it is not the loader: the
WebView2Loader.dll your CI pins from NuGet 1.0.4191.47 and the one shipped
in craft-windows-x64.zip are the same file, sha256
c66e4a92fdc7a216118e43b7a5024ea2200e8c43f9310bf20d96a0084f82c5bc (and that
NuGet download matches the digest pinned in ci.yml, so I have the same bytes
you do). Both import only KERNEL32 and reference ole32.dll by name for
dynamic resolution. --core in build:core only changes the working
directory, so your CI build flags match the release build's.
Since writing that, I measured it. It is neither candidate: it is
CREATE_NO_WINDOW. Six argument lists, including my bundle's exact one, all
produce a webview against the unpatched shipped binary on windows-latest.
Holding the arguments fixed and varying only the spawn:
piped stdio, no windowsHide loaded in 490ms
piped stdio + windowsHide no webview 0x800401f0
inherit stdio + windowsHide no webview 0x800401f0
inherit stdio, no windowsHide loaded in 467ms
GUI-subsystem parent, windowsHide no webview 0x800401f0windowsHide is Bun's name for CREATE_NO_WINDOW, which my launcher sets so
Windows does not put a console window beside the app. It is sufficient on its
own; stdio and the parent's subsystem make no difference.
So the apartment craft relies on today is created by something tied to console
attachment. Your windows-multi-window job spawns craft with a console and
gets one; my launcher takes the console away and nothing does, because craft
never does it itself. The comments below carry the full tables.
The change, which stands either way
Window.create calls CoInitializeEx(null, COINIT_APARTMENTTHREADED) before
anything touches WebView2. It is the single funnel every window goes through —
api.zig dispatches to it and loadWebView2 is reached only from inside it —
and a thread-local guard keeps later windows from repeating the call.
An STA, because WebView2 hosts UI on the thread that pumps messages. Two
non-S_OK results are accepted rather than treated as failures:
S_FALSE— the thread already had an apartment. This is the case in your CI today if something there is already initialising one, and it stays working.RPC_E_CHANGED_MODE— an embedder got there first and chose an MTA. That is theirs to choose and WebView2 works in it, so craft neither overrides it nor complains.
There is deliberately no matching CoUninitialize: the apartment has to
outlive every webview created on the thread, and the only point at which that
holds is process exit, which tears it down anyway. No new link dependency —
build.zig already links ole32.
A WebView2 host establishing its own apartment is the documented expectation rather than a workaround, so this is worth doing even where the dependency happens to be satisfied today.
Verification
Cross-compiled x86_64-windows from macOS before and after, and parsed the PE
import directory of each craft.exe:
before ole32.dll: CoTaskMemFree
after ole32.dll: CoInitializeEx, CoTaskMemFreeEvery other imported DLL is identical in count, so the patch adds exactly one
symbol and moves nothing else. zig fmt --check is clean. Built with
-Djs-runtime=false, since the zig-js path dependency is a sibling checkout
I do not have.
I have not run it: I am on macOS with no Windows machine. The offer from
#335 stands — I have a Windows CI job that reproduces the failure against the
shipped zip, and I am glad to run the discriminating experiment there (shipped
binary, --url alone versus the flags above) and post the result, which would
also tell us which difference above is the one that matters.
Found while building a Windows desktop bundle for
stacksjs/system-cleaner
(stacksjs/system-cleaner#23), where everything up to the webview works: the
loopback agent binds, its health endpoint answers, and craft.exe receives the
URL, title and size.
🤖 Generated with Claude Code