Four real bugs found and fixed while getting the example gallery building
and running on the Windows target. Each one is separated below with the
root cause, the fix, and the code. See WINDOWS-BUILD-RESULTS.md for the
full per-app pass/fail picture; this file is the detailed changelog for
what was actually corrected in the framework.
console global binding — link failureSymptom: bubble-grid, bubble-grid-jsx, and gea-bench failed to
link for the Windows target:
lld-link: error: undefined symbol: struct gea::Ref<struct gea_record_type_245> console
>>> referenced by ...grid.cxx.obj:(void __cdecl gea_body_fn_decl_f61_1861(double))
Root cause: @geastack/core/index.d.ts declared the ambient console
global with an anonymous object type:
const console: {
log(message: string): void
error(message: string): void
}
The compiler recognizes a global as a real native host binding
(gea::host::console) only when its declared type is a nominal
interface whose name matches an entry in its own capability table
(host-members.ts / capabilities.ts) — the same mechanism that claims
Math, Date, and String. Console@1 is one of those claimed names.
An anonymous object type has no symbol name to match against, so the
compiler fell through to its generic fallback: box the global as a plain
record and emit an extern declaration for it, with nothing anywhere
that ever defines it. That compiles cleanly and links fine — right up
until a translation unit actually calls console.log(...), which is the
first point something requires the symbol to exist. Apps that never call
console.* (most of the gallery) never hit this, which is why it looked
like an isolated problem in only a few apps rather than a framework gap.
Confirmed in the generated output before the fix
(bubble-grid/dist/windows/.generated/bubble-grid/index.hpp):
extern gea::Ref<gea_record_type_245> console;
versus an app whose tsconfig.json includes DOM lib (so console
resolves to the real, nominal lib.dom.d.ts Console interface and
never hits this path):
extern gea::NativeHandle<gea_native_protocol_Console_v1> console;
Fix — core/packages/core/index.d.ts (mirrored into
examples/node_modules/@geastack/core/index.d.ts so example builds pick
it up without a republish):
- const console: {
+ interface Console {
log(message: string): void
error(message: string): void
+ info(message: string): void
}
+ const console: Console
Giving console a real, named Console interface makes the compiler's
name-based protocol matching claim it correctly, so calls lower straight
to the real gea::host::console::log/error/info (already implemented
inline in the shared runtime header, gea_runtime.h — no per-platform
work needed) instead of the dead boxed-record path. info was added
alongside log/error because the runtime already implements it (as an
alias of log) — the previous declaration's comment claiming only two
methods exist was itself stale.
Verified: all three apps build, link, and run after a full cache wipe
(see the caching note in WINDOWS-BUILD-RESULTS.md — --clean alone
isn't enough here, because Vite's .vite dep-cache can still serve the
pre-fix generated output).
Symptom: weather's UI never populated with real data. Every fetch
reported status=200, ok=true, but the response body was always empty
(raw.length === 0).
Root cause: in @geastack/windows's win32_network.cpp, the WinHTTP
URL_COMPONENTS used to split a URL only provided buffers for the host
and path:
wchar_t host[256] = {0};
wchar_t path[4096] = {0};
parts.lpszHostName = host;
parts.dwHostNameLength = 256;
parts.lpszUrlPath = path;
parts.dwUrlPathLength = 4096;
WinHttpCrackUrl splits a URL's query string into a separate field,
lpszExtraInfo — it does not fold it into lpszUrlPath. Since that field
was never set, the query string was silently discarded on every request.
A call to:
https://api.open-meteo.com/v1/forecast?latitude=38.7223&longitude=-9.1393&...
actually went out over the wire as:
GET /v1/forecast
Confirmed independently that Open-Meteo's API answers a bare,
query-less /v1/forecast with 200 OK and an empty body (checked
with Invoke-WebRequest/curl directly) — exactly the "success but
nothing" shape the app saw. Every fetch in the whole framework that uses
a query string (the overwhelmingly common case) was silently broken on
Windows.
Fix — add an lpszExtraInfo buffer, and concatenate it onto the path
before opening the request:
wchar_t host[256] = {0};
wchar_t path[4096] = {0};
+wchar_t extra[4096] = {0};
parts.lpszHostName = host;
parts.dwHostNameLength = 256;
parts.lpszUrlPath = path;
parts.dwUrlPathLength = 4096;
+parts.lpszExtraInfo = extra;
+parts.dwExtraInfoLength = 4096;
if (!WinHttpCrackUrl(wideUrl.c_str(), static_cast<DWORD>(wideUrl.size()), 0, &parts)) {
...
}
...
const std::string method = init.method.empty() ? "GET" : init.method;
-HINTERNET request = WinHttpOpenRequest(connection, widen(method).c_str(), path, nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
- secure ? WINHTTP_FLAG_SECURE : 0);
+const std::wstring pathWithQuery = std::wstring(path) + extra;
+HINTERNET request = WinHttpOpenRequest(connection, widen(method).c_str(), pathWithQuery.c_str(), nullptr, WINHTTP_NO_REFERER,
+ WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0);
Verified: with debug logging temporarily added to sessionFetch, the
request line now shows the full path:
[win32 net DBG] host=api.open-meteo.com path=/v1/forecast?latitude=38.7223&longitude=-9.1393¤t=...
Symptom: even after fix #2 corrected the URL, the body was still empty. Digging further with stderr instrumentation in the read loop:
DWORD available = 0;
BOOL avail_ok = WinHttpQueryDataAvailable(request, &available);
// avail_ok=0 available=0 lastError=2147500036 (0x80004004 == E_ABORT)
Root cause: sharedSession() turned on automatic decompression:
HINTERNET created = WinHttpOpen(...);
if (created) {
DWORD decompression = WINHTTP_DECOMPRESSION_FLAG_ALL;
WinHttpSetOption(created, WINHTTP_OPTION_DECOMPRESSION, &decompression, sizeof(decompression));
DWORD protocols = WINHTTP_PROTOCOL_FLAG_HTTP2;
WinHttpSetOption(created, WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, &protocols, sizeof(protocols));
}
Enabling WINHTTP_OPTION_DECOMPRESSION makes WinHttp automatically send
an Accept-Encoding header advertising the compression schemes it
supports, and transparently decompress the response. Open-Meteo is
fronted by Cloudflare, which answered with a Content-Encoding this
particular WinHTTP build can't decode (Brotli) — and rather than falling
back to returning the raw compressed bytes, WinHttp's automatic
decompression layer fails the entire read from inside
WinHttpQueryDataAvailable, surfacing as the HRESULT-shaped
GetLastError() value 0x80004004 (E_ABORT) instead of a normal
WinHTTP error code. status and headers had already been read
successfully by this point (hence status=200 looking fine), only the
body read failed — which is why the bug looked like "success with an
empty response" rather than an obvious failure.
(The WINHTTP_PROTOCOL_FLAG_HTTP2 option was tested independently and
ruled out — removing it alone did not fix the empty body. It was removed
anyway for simplicity, since WinHttp negotiates HTTP/2 automatically when
supported regardless of this flag.)
Fix — stop asking for automatic decompression. Every real caller in this codebase parses the body as JSON text anyway, so a plain, uncompressed response is exactly what's wanted:
+// No WINHTTP_OPTION_DECOMPRESSION here: turning it on makes WinHttp send an
+// Accept-Encoding a Cloudflare-fronted API answers with a Content-Encoding
+// this WinHttp build can't decode (br), and the automatic-decompression
+// layer then fails the whole read from inside WinHttpQueryDataAvailable --
+// GetLastError() 0x80004004 (E_ABORT), body always empty, status still 200.
+// Leaving compression off gets a plain-text body every real caller here
+// parses as JSON anyway.
HINTERNET sharedSession()
{
- static HINTERNET session = [] {
- HINTERNET created = WinHttpOpen(L"gea-windows/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
- if (created) {
- DWORD decompression = WINHTTP_DECOMPRESSION_FLAG_ALL;
- WinHttpSetOption(created, WINHTTP_OPTION_DECOMPRESSION, &decompression, sizeof(decompression));
- DWORD protocols = WINHTTP_PROTOCOL_FLAG_HTTP2;
- WinHttpSetOption(created, WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, &protocols, sizeof(protocols));
- }
- return created;
- }();
+ static HINTERNET session = WinHttpOpen(L"gea-windows/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
return session;
}
Verified: with both fixes #2 and #3 in place, the read loop returns real data:
DBG fetch ok=true status=200
DBG raw length=2502
DBG applyApiWeather done, temp=91
Symptom: weather's condition icon showed every weather image
(clear/cloud/rain/snow/fog) stacked on top of each other simultaneously,
instead of only the active one.
Root cause: WeatherVisual.tsx renders one <img> per condition,
absolutely stacked, and hides the inactive ones by design via CSS
opacity (not display: none, so switching conditions doesn't need to
decode a new image):
<img
class={{ 'weather-img': true, 'is-hidden': weather.visual != 'cloud' }}
src="assets/weather/weather-cloud.png"
fit="contain"
/>
.weather-img.is-hidden {
opacity: 0;
}
In @geastack/windows's win32_widgets.cpp, the function that paints
every <img> element, paintBitmap(), hardcoded full opacity in the
GDI+ blend call:
BLENDFUNCTION blendFunction{};
blendFunction.BlendOp = AC_SRC_OVER;
blendFunction.SourceConstantAlpha = 255;
blendFunction.AlphaFormat = AC_SRC_ALPHA;
Every other paint path in the same renderer — box fills, text — does
multiply its alpha by the element's style.opacity (e.g.
win32_renderer.cpp: const int alpha = box.fill.a * box.opacity / 255;).
The image path never did. So CSS opacity had no effect on <img>
elements at all on Windows, regardless of app — this wasn't
weather-specific, it just happened to be the first app in the gallery
whose UI depended on it.
Fix — thread the element's opacity through to paintBitmap().
win32_widgets.h:
-// Paints a 32bpp premultiplied bitmap into `dest` according to `contentMode`.
-void paintBitmap(HDC hdc, HBITMAP bitmap, int bitmapWidth, int bitmapHeight, const RECT &dest, int contentMode);
+// Paints a 32bpp premultiplied bitmap into `dest` according to `contentMode`,
+// at `opacity` (0-255, the element's CSS opacity -- not the bitmap's own
+// per-pixel alpha, which is already baked into `bitmap`).
+void paintBitmap(HDC hdc, HBITMAP bitmap, int bitmapWidth, int bitmapHeight, const RECT &dest, int contentMode, std::uint8_t opacity = 255);
win32_widgets.cpp:
-void paintBitmap(HDC hdc, HBITMAP bitmap, int bitmapWidth, int bitmapHeight, const RECT &dest, int contentMode)
+void paintBitmap(HDC hdc, HBITMAP bitmap, int bitmapWidth, int bitmapHeight, const RECT &dest, int contentMode, std::uint8_t opacity)
{
+ if (opacity == 0) return;
if (!bitmap || bitmapWidth <= 0 || bitmapHeight <= 0) return;
...
BLENDFUNCTION blendFunction{};
blendFunction.BlendOp = AC_SRC_OVER;
- blendFunction.SourceConstantAlpha = 255;
+ blendFunction.SourceConstantAlpha = opacity;
blendFunction.AlphaFormat = AC_SRC_ALPHA;
win32_renderer.cpp (the NodeType::Image call site):
if (HBITMAP bitmap = bitmapForImageId(node.image_id, &bitmapWidth, &bitmapHeight)) {
- paintBitmap(context.hdc, bitmap, bitmapWidth, bitmapHeight, rect, node.style.image_fit);
+ paintBitmap(context.hdc, bitmap, bitmapWidth, bitmapHeight, rect, node.style.image_fit, node.style.opacity);
}
The early if (opacity == 0) return; is a cheap skip for the fully
transparent (and by far the most common: 4 of every 5 stacked condition
icons at any moment) case, avoiding the bitmap scaling/blend work
entirely rather than doing it just to blend at zero alpha.
Verified: rebuilt and ran Weather.exe — the correct single icon now
shows per condition, confirmed visually.
● Update(C:\repos\geastack\windows\packages\geastack-windows\targets\win32\main\win32_main.cpp)
⎿ Added 1 line
17 #include "css/engine.h"
18 #include "display.h"
19 #include "host/storage.h"
20 +#include "platform/file_cache.h"
21 #include "services/storage_service.h"
22 #include "ui/document.h"
23 #include "ui/node.h"
● Update(C:\repos\geastack\windows\packages\geastack-windows\targets\win32\main\win32_main.cpp)
⎿ Added 7 lines
586 gea::win32::installAppLauncherPlatform(GEA_WINDOWS_APP_ID);
587 gea::win32::storageAppId() = GEA_WINDOWS_APP_ID;
588 gea::framework::services::StorageService::init();
589 + // Unlike an ESP32 board (which needs to detect a microSD card before the
590 + // persistent file cache is safe to use), a Windows host always has a
591 + // writable filesystem, so the mount check that gates
592 + // readCacheFile/writeCacheFile/listCacheFiles (image.cpp) can simply
593 + // always succeed here. Without this, no provider is ever registered on
594 + // this target and every one of those calls silently no-ops.
● Update(node_modules\@geastack\windows\targets\win32\main\win32_main.cpp)
⎿ Added 1 line
17 #include "css/engine.h"
18 #include "display.h"
19 #include "host/storage.h"
20 +#include "platform/file_cache.h"
21 #include "services/storage_service.h"
22 #include "ui/document.h"
23 #include "ui/node.h"
● Update(node_modules\@geastack\windows\targets\win32\main\win32_main.cpp)
⎿ Added 7 lines
586 gea::win32::installAppLauncherPlatform(GEA_WINDOWS_APP_ID);
587 gea::win32::storageAppId() = GEA_WINDOWS_APP_ID;
588 gea::framework::services::StorageService::init();
589 + // Unlike an ESP32 board (which needs to detect a microSD card before the
590 + // persistent file cache is safe to use), a Windows host always has a
591 + // writable filesystem, so the mount check that gates
592 + // readCacheFile/writeCacheFile/listCacheFiles (image.cpp) can simply
593 + // always succeed here. Without this, no provider is ever registered on
594 + // this target and every one of those calls silently no-ops.
595 + gea::platform::storage::setMountProvider([]() -> bool { return true; });
596 // Restore persisted localStorage BEFORE Application::init: a store's
597 // init() reads it during mount.
598 gea::host::Storage.load();
Root cause of "nothing renders"
You'd correctly placed config.json + issues.jsonl at dist\windows\pellets\pellets-board, but the Windows build was silently discarding every file read/write. readCacheFile/writeCacheFile require a "storage mount" to be registered at startup, and the Windows target never registered one — only the ESP32 board targets and the web simulator do. So every read returned empty bytes and every write silently no-op'd, regardless of what files were on disk.
I fixed this with a small, targeted patch to win32_main.cpp (in @geastack/windows): registered an "always mounted" provider, since — unlike an ESP32 board that might not have an SD card inserted — a Windows host always has a writable filesystem.