From 333b0dbf52ce66b2f68e6b85f8395b5d29a96755 Mon Sep 17 00:00:00 2001 From: Tim Ebbeke Date: Fri, 17 Apr 2026 20:59:46 +0200 Subject: [PATCH 1/4] Fixed RPC regression introduced by u64 change. --- nui/include/nui/frontend/api/throttle.hpp | 3 +- nui/include/nui/frontend/api/timer.hpp | 4 +- nui/include/nui/frontend/filesystem/file.hpp | 14 ++-- nui/include/nui/shared/api/fetch_options.hpp | 2 +- nui/include/nui/window.hpp | 7 +- nui/src/nui/backend/rpc_addons/file.cpp | 71 ++++++++++++-------- nui/src/nui/backend/rpc_addons/throttle.cpp | 7 +- nui/src/nui/backend/rpc_addons/timer.cpp | 7 +- nui/src/nui/backend/rpc_hub.cpp | 4 +- nui/src/nui/backend/window.cpp | 8 +-- nui/src/nui/frontend/api/throttle.cpp | 2 +- nui/src/nui/frontend/api/timer.cpp | 4 +- nui/src/nui/frontend/filesystem/file.cpp | 20 +++--- nui/src/nui/frontend/window.cpp | 6 +- 14 files changed, 92 insertions(+), 67 deletions(-) diff --git a/nui/include/nui/frontend/api/throttle.hpp b/nui/include/nui/frontend/api/throttle.hpp index 91098080..aa479d32 100644 --- a/nui/include/nui/frontend/api/throttle.hpp +++ b/nui/include/nui/frontend/api/throttle.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -40,7 +41,7 @@ namespace Nui * @param callWhenReady call the function when ready if it was not during the wait interval. */ void throttle( - int milliseconds, + std::int32_t milliseconds, std::function toWrap, std::function callback, bool callWhenReady = false); diff --git a/nui/include/nui/frontend/api/timer.hpp b/nui/include/nui/frontend/api/timer.hpp index 26b40a5f..d13baadd 100644 --- a/nui/include/nui/frontend/api/timer.hpp +++ b/nui/include/nui/frontend/api/timer.hpp @@ -37,7 +37,7 @@ namespace Nui * @param toWrap The function to call periodically. * @param callback This callback receives the interval handle. The timer can be stopped using this handle. */ - void setInterval(int milliseconds, std::function toWrap, std::function callback); + void setInterval(std::int32_t milliseconds, std::function toWrap, std::function callback); /** * @brief Creates a new delayed function that calls "toWrap" after "milliseconds" milliseconds. @@ -49,5 +49,5 @@ namespace Nui * @param toWrap The function to call after the timeout. * @param callback This callback receives the timeout handle. The timeout can be stopped using this handle. */ - void setTimeout(int milliseconds, std::function toWrap, std::function callback); + void setTimeout(std::int32_t milliseconds, std::function toWrap, std::function callback); } \ No newline at end of file diff --git a/nui/include/nui/frontend/filesystem/file.hpp b/nui/include/nui/frontend/filesystem/file.hpp index dff32542..cc59cffd 100644 --- a/nui/include/nui/frontend/filesystem/file.hpp +++ b/nui/include/nui/frontend/filesystem/file.hpp @@ -32,20 +32,20 @@ namespace Nui public: ~AsyncFile(); - void tellg(std::function cb) const; - void tellp(std::function cb) const; - void seekg(int32_t pos, std::function cb, std::ios_base::seekdir dir = std::ios_base::beg); - void seekp(int32_t pos, std::function cb, std::ios_base::seekdir dir = std::ios_base::beg); + void tellg(std::function cb) const; + void tellp(std::function cb) const; + void seekg(std::int64_t pos, std::function cb, std::ios_base::seekdir dir = std::ios_base::beg); + void seekp(std::int64_t pos, std::function cb, std::ios_base::seekdir dir = std::ios_base::beg); - void read(int32_t size, std::function cb); + void read(std::int32_t size, std::function cb); void readAll(std::function cb); void write(std::string const& data, std::function cb); private: - AsyncFile(int32_t id); + AsyncFile(std::int32_t id); private: - int32_t fileId_; + std::int32_t fileId_; }; void openFile( char const* filename, diff --git a/nui/include/nui/shared/api/fetch_options.hpp b/nui/include/nui/shared/api/fetch_options.hpp index 919cc1ff..7c9fce57 100644 --- a/nui/include/nui/shared/api/fetch_options.hpp +++ b/nui/include/nui/shared/api/fetch_options.hpp @@ -20,7 +20,7 @@ namespace Nui std::string body = ""; bool verbose = false; bool followRedirects = false; - long maxRedirects = 255; + std::int32_t maxRedirects = 255; bool autoReferer = false; // body is encoded as base64 to get it through to the webview. setting this option preserves the body as base64 bool dontDecodeBody = false; diff --git a/nui/include/nui/window.hpp b/nui/include/nui/window.hpp index 59facd05..a1fa6992 100644 --- a/nui/include/nui/window.hpp +++ b/nui/include/nui/window.hpp @@ -8,6 +8,7 @@ # include #endif +#include #include #include #include @@ -16,7 +17,7 @@ namespace Nui { - enum class WebViewHint : int + enum class WebViewHint : std::int32_t { WEBVIEW_HINT_NONE, WEBVIEW_HINT_MIN, @@ -198,7 +199,7 @@ namespace Nui * @param height * @param hint */ - void setSize(int width, int height, WebViewHint hint = WebViewHint::WEBVIEW_HINT_NONE); + void setSize(std::int32_t width, std::int32_t height, WebViewHint hint = WebViewHint::WEBVIEW_HINT_NONE); /** * @brief Sets the position of the window @@ -207,7 +208,7 @@ namespace Nui * @param y yCoordinate * @param (MacOS only) use setFrameOrigin instead of setFrameTopLeftPoint (see apple doc) */ - void setPosition(int x, int y, bool useFrameOrigin = true); + void setPosition(std::int32_t x, std::int32_t y, bool useFrameOrigin = true); /** * @brief Center the window on the primary display. Requires size to be set first. diff --git a/nui/src/nui/backend/rpc_addons/file.cpp b/nui/src/nui/backend/rpc_addons/file.cpp index a581944a..8b4910d9 100644 --- a/nui/src/nui/backend/rpc_addons/file.cpp +++ b/nui/src/nui/backend/rpc_addons/file.cpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -35,7 +36,8 @@ namespace Nui void registerFile(Nui::RpcHub& hub) { hub.registerFunction( - "Nui::openFile", [&hub](std::string const& responseId, std::string const& fileName, int openMode) { + "Nui::openFile", + [&hub](std::string const& responseId, std::string const& fileName, std::int32_t openMode) { auto& store = Detail::getStore(hub); std::fstream stream(fileName, static_cast(openMode)); if (!stream.is_open()) @@ -44,42 +46,53 @@ namespace Nui return; } - hub.callRemote(responseId, nlohmann::json{{"success", true}, {"id", store.append(std::move(stream))}}); + // Registry id is size_t; cast to int32_t so the wire type + // matches the frontend handler and bypasses normalizeCallRemoteArg's + // split-u64 path for uint64_t. + const auto id = static_cast(store.append(std::move(stream))); + hub.callRemote(responseId, nlohmann::json{{"success", true}, {"id", id}}); }); - hub.registerFunction("Nui::closeFile", [&hub](int32_t id) { + hub.registerFunction("Nui::closeFile", [&hub](std::int32_t id) { auto& store = Detail::getStore(hub); store.erase(static_cast::IdType>(id)); }); - hub.registerFunction("Nui::tellg", [&hub](std::string const& responseId, int32_t id) { + hub.registerFunction("Nui::tellg", [&hub](std::string const& responseId, std::int32_t id) { auto& store = Detail::getStore(hub); auto& stream = store[static_cast::IdType>(id)]; - hub.callRemote(responseId, static_cast(stream.tellg())); + // File positions can exceed 2 GiB — use int64_t so the RPC ships + // them via the {_u64_hi,_u64_lo} path and the frontend decodes them + // without truncation. + hub.callRemote(responseId, static_cast(stream.tellg())); }); - hub.registerFunction("Nui::tellp", [&hub](std::string const& responseId, int32_t id) { + hub.registerFunction("Nui::tellp", [&hub](std::string const& responseId, std::int32_t id) { auto& store = Detail::getStore(hub); auto& stream = store[static_cast::IdType>(id)]; - hub.callRemote(responseId, static_cast(stream.tellp())); + hub.callRemote(responseId, static_cast(stream.tellp())); }); - hub.registerFunction("Nui::seekg", [&hub](std::string const& responseId, int32_t id, int32_t pos, int32_t dir) { - auto& store = Detail::getStore(hub); - auto& stream = store[static_cast::IdType>(id)]; - stream.seekg(pos, static_cast(dir)); - hub.callRemote(responseId); - }); - hub.registerFunction("Nui::seekp", [&hub](std::string const& responseId, int32_t id, int32_t pos, int32_t dir) { - auto& store = Detail::getStore(hub); - auto& stream = store[static_cast::IdType>(id)]; - stream.seekp(pos, static_cast(dir)); - hub.callRemote(responseId); - }); - hub.registerFunction("Nui::read", [&hub](std::string const& responseId, int32_t id, int32_t size) { + hub.registerFunction( + "Nui::seekg", + [&hub](std::string const& responseId, std::int32_t id, std::int64_t pos, std::int32_t dir) { + auto& store = Detail::getStore(hub); + auto& stream = store[static_cast::IdType>(id)]; + stream.seekg(static_cast(pos), static_cast(dir)); + hub.callRemote(responseId); + }); + hub.registerFunction( + "Nui::seekp", + [&hub](std::string const& responseId, std::int32_t id, std::int64_t pos, std::int32_t dir) { + auto& store = Detail::getStore(hub); + auto& stream = store[static_cast::IdType>(id)]; + stream.seekp(static_cast(pos), static_cast(dir)); + hub.callRemote(responseId); + }); + hub.registerFunction("Nui::read", [&hub](std::string const& responseId, std::int32_t id, std::int32_t size) { auto& store = Detail::getStore(hub); auto& stream = store[static_cast::IdType>(id)]; std::string buffer(static_cast(size), '\0'); - stream.read(buffer.data(), size); + stream.read(buffer.data(), static_cast(size)); hub.callRemote(responseId, buffer); }); - hub.registerFunction("Nui::readAll", [&hub](std::string const& responseId, int32_t id) { + hub.registerFunction("Nui::readAll", [&hub](std::string const& responseId, std::int32_t id) { auto& store = Detail::getStore(hub); auto& stream = store[static_cast::IdType>(id)]; std::string buffer; @@ -89,11 +102,13 @@ namespace Nui stream.read(buffer.data(), static_cast(buffer.size())); hub.callRemote(responseId, buffer); }); - hub.registerFunction("Nui::write", [&hub](std::string const& responseId, int32_t id, std::string const& data) { - auto& store = Detail::getStore(hub); - auto& stream = store[static_cast::IdType>(id)]; - stream.write(data.data(), static_cast(data.size())); - hub.callRemote(responseId); - }); + hub.registerFunction( + "Nui::write", + [&hub](std::string const& responseId, std::int32_t id, std::string const& data) { + auto& store = Detail::getStore(hub); + auto& stream = store[static_cast::IdType>(id)]; + stream.write(data.data(), static_cast(data.size())); + hub.callRemote(responseId); + }); } } \ No newline at end of file diff --git a/nui/src/nui/backend/rpc_addons/throttle.cpp b/nui/src/nui/backend/rpc_addons/throttle.cpp index a0b1f440..0e6856f1 100644 --- a/nui/src/nui/backend/rpc_addons/throttle.cpp +++ b/nui/src/nui/backend/rpc_addons/throttle.cpp @@ -134,7 +134,12 @@ namespace Nui std::make_shared( std::chrono::milliseconds(period), callWhenReady, &hub, hub.window().getExecutor())); store[id]->setId(id); - hub.callRemote(responseId, id); + // Cast: registry IdType is size_t (= uint64_t on 64-bit), which + // normalizeCallRemoteArg would ship as a {_u64_hi,_u64_lo} + // object — but the frontend temp handler is declared + // `(int32_t throttleId)` and can't decode that shape, so the + // callback would silently never fire. + hub.callRemote(responseId, static_cast(id)); }); hub.registerFunction("Nui::removeThrottle", [&hub](int32_t id) { auto& store = Detail::getStore(hub); diff --git a/nui/src/nui/backend/rpc_addons/timer.cpp b/nui/src/nui/backend/rpc_addons/timer.cpp index d53ceab5..1fed5d26 100644 --- a/nui/src/nui/backend/rpc_addons/timer.cpp +++ b/nui/src/nui/backend/rpc_addons/timer.cpp @@ -122,7 +122,10 @@ namespace Nui auto& timer = store[id]; timer->setId(id); timer->start(); - hub.callRemote(responseId, id); + // See throttle.cpp: size_t registry id would go through + // normalizeCallRemoteArg as a split-u64 object, but the frontend + // handler is typed `(int32_t timerId)` and can't decode that. + hub.callRemote(responseId, static_cast(id)); }); hub.registerFunction("Nui::setTimeout", [&hub](std::string const& responseId, int32_t period) { auto& store = Detail::getStore(hub); @@ -132,7 +135,7 @@ namespace Nui auto& timer = store[id]; timer->setId(id); timer->start(); - hub.callRemote(responseId, id); + hub.callRemote(responseId, static_cast(id)); }); hub.registerFunction("Nui::removeTimer", [&hub](int32_t id) { Detail::eraseTimerInstance(&hub, id); diff --git a/nui/src/nui/backend/rpc_hub.cpp b/nui/src/nui/backend/rpc_hub.cpp index ef111915..36375c36 100644 --- a/nui/src/nui/backend/rpc_hub.cpp +++ b/nui/src/nui/backend/rpc_hub.cpp @@ -61,13 +61,13 @@ namespace Nui registerFunction("Nui::terminate", [this]() { window_->terminate(); }); - registerFunction("Nui::setWindowSize", [this](int width, int height, int hint) { + registerFunction("Nui::setWindowSize", [this](std::int32_t width, std::int32_t height, std::int32_t hint) { window_->setSize(width, height, static_cast(hint)); }); registerFunction("Nui::setWindowTitle", [this](std::string const& title) { window_->setTitle(title); }); - registerFunction("Nui::setPosition", [this](int x, int y, bool useFrameOrigin) { + registerFunction("Nui::setPosition", [this](std::int32_t x, std::int32_t y, bool useFrameOrigin) { window_->setPosition(x, y, useFrameOrigin); }); registerFunction("Nui::centerOnPrimaryDisplay", [this]() { diff --git a/nui/src/nui/backend/window.cpp b/nui/src/nui/backend/window.cpp index 5fd484ee..2dc2ad79 100644 --- a/nui/src/nui/backend/window.cpp +++ b/nui/src/nui/backend/window.cpp @@ -118,8 +118,8 @@ namespace Nui std::unique_ptr view; std::vector cleanupFiles; std::unordered_map> callbacks; - int width; - int height; + std::int32_t width; + std::int32_t height; std::function onRpcError; std::function onRpcAliveMessage; bool isRunning{false}; @@ -308,7 +308,7 @@ namespace Nui impl_->view->set_title(title); } //--------------------------------------------------------------------------------------------------------------------- - void Window::setSize(int width, int height, WebViewHint hint) + void Window::setSize(std::int32_t width, std::int32_t height, WebViewHint hint) { std::scoped_lock lock{impl_->viewGuard}; impl_->width = width; @@ -512,7 +512,7 @@ namespace Nui impl_->view->terminate(); } //--------------------------------------------------------------------------------------------------------------------- - void Window::setPosition(int x, int y, bool useFrameOrigin) + void Window::setPosition(std::int32_t x, std::int32_t y, bool useFrameOrigin) { std::scoped_lock lock{impl_->viewGuard}; #if defined(_WIN32) diff --git a/nui/src/nui/frontend/api/throttle.cpp b/nui/src/nui/frontend/api/throttle.cpp index 5f6c53ae..c152c014 100644 --- a/nui/src/nui/frontend/api/throttle.cpp +++ b/nui/src/nui/frontend/api/throttle.cpp @@ -55,7 +55,7 @@ namespace Nui } // ##################################################################################################################### void throttle( - int milliseconds, + std::int32_t milliseconds, std::function toWrap, std::function callback, bool callWhenReady) diff --git a/nui/src/nui/frontend/api/timer.cpp b/nui/src/nui/frontend/api/timer.cpp index feab49ea..6066c0a9 100644 --- a/nui/src/nui/frontend/api/timer.cpp +++ b/nui/src/nui/frontend/api/timer.cpp @@ -46,7 +46,7 @@ namespace Nui return id_ != -1; } // ##################################################################################################################### - void setInterval(int milliseconds, std::function toWrap, std::function callback) + void setInterval(std::int32_t milliseconds, std::function toWrap, std::function callback) { RpcClient::getRemoteCallableWithBackChannel( "Nui::setInterval", [callback = std::move(callback), toWrap = std::move(toWrap)](int32_t timerId) { @@ -57,7 +57,7 @@ namespace Nui })(milliseconds); } //--------------------------------------------------------------------------------------------------------------------- - void setTimeout(int milliseconds, std::function toWrap, std::function callback) + void setTimeout(std::int32_t milliseconds, std::function toWrap, std::function callback) { RpcClient::getRemoteCallableWithBackChannel( "Nui::setTimeout", [callback = std::move(callback), toWrap = std::move(toWrap)](int32_t timerId) { diff --git a/nui/src/nui/frontend/filesystem/file.cpp b/nui/src/nui/frontend/filesystem/file.cpp index 2296b8a0..bd3754e2 100644 --- a/nui/src/nui/frontend/filesystem/file.cpp +++ b/nui/src/nui/frontend/filesystem/file.cpp @@ -8,7 +8,7 @@ namespace Nui { // ##################################################################################################################### - AsyncFile::AsyncFile(int32_t id) + AsyncFile::AsyncFile(std::int32_t id) : fileId_{id} {} //--------------------------------------------------------------------------------------------------------------------- @@ -20,29 +20,29 @@ namespace Nui } } //--------------------------------------------------------------------------------------------------------------------- - void AsyncFile::tellg(std::function cb) const + void AsyncFile::tellg(std::function cb) const { RpcClient::getRemoteCallableWithBackChannel("Nui::tellg", std::move(cb))(fileId_); } //--------------------------------------------------------------------------------------------------------------------- - void AsyncFile::tellp(std::function cb) const + void AsyncFile::tellp(std::function cb) const { RpcClient::getRemoteCallableWithBackChannel("Nui::tellp", std::move(cb))(fileId_); } //--------------------------------------------------------------------------------------------------------------------- - void AsyncFile::seekg(int32_t pos, std::function cb, std::ios_base::seekdir dir) + void AsyncFile::seekg(std::int64_t pos, std::function cb, std::ios_base::seekdir dir) { RpcClient::getRemoteCallableWithBackChannel("Nui::seekg", std::move(cb))( - fileId_, pos, static_cast(dir)); + fileId_, pos, static_cast(dir)); } //--------------------------------------------------------------------------------------------------------------------- - void AsyncFile::seekp(int32_t pos, std::function cb, std::ios_base::seekdir dir) + void AsyncFile::seekp(std::int64_t pos, std::function cb, std::ios_base::seekdir dir) { RpcClient::getRemoteCallableWithBackChannel("Nui::seekp", std::move(cb))( - fileId_, pos, static_cast(dir)); + fileId_, pos, static_cast(dir)); } //--------------------------------------------------------------------------------------------------------------------- - void AsyncFile::read(int32_t size, std::function cb) + void AsyncFile::read(std::int32_t size, std::function cb) { RpcClient::getRemoteCallableWithBackChannel("Nui::read", std::move(cb))(fileId_, size); } @@ -77,10 +77,10 @@ namespace Nui return; } - int32_t id; + std::int32_t id; convertFromVal(response["id"], id); onOpen(AsyncFile{id}); - })(filename, static_cast(mode)); + })(filename, static_cast(mode)); } //--------------------------------------------------------------------------------------------------------------------- void openFile( diff --git a/nui/src/nui/frontend/window.cpp b/nui/src/nui/frontend/window.cpp index c80ddeb4..c8053d15 100644 --- a/nui/src/nui/frontend/window.cpp +++ b/nui/src/nui/frontend/window.cpp @@ -40,9 +40,9 @@ namespace Nui RpcClient::getRemoteCallable("Nui::setWindowTitle")(title); } //--------------------------------------------------------------------------------------------------------------------- - void Window::setSize(int width, int height, WebViewHint hint) + void Window::setSize(std::int32_t width, std::int32_t height, WebViewHint hint) { - RpcClient::getRemoteCallable("Nui::setWindowSize")(width, height, static_cast(hint)); + RpcClient::getRemoteCallable("Nui::setWindowSize")(width, height, static_cast(hint)); } //--------------------------------------------------------------------------------------------------------------------- void Window::navigate(const std::string& location) @@ -60,7 +60,7 @@ namespace Nui RpcClient::getRemoteCallable("Nui::openDevTools")(); } //--------------------------------------------------------------------------------------------------------------------- - void Window::setPosition(int x, int y, bool useFrameOrigin) + void Window::setPosition(std::int32_t x, std::int32_t y, bool useFrameOrigin) { RpcClient::getRemoteCallable("Nui::setPosition")(x, y, useFrameOrigin); } From 40ce1f588cabb1b3118176153ac80486ac785dc4 Mon Sep 17 00:00:00 2001 From: Tim Ebbeke Date: Fri, 17 Apr 2026 19:34:11 +0200 Subject: [PATCH 2/4] Improved file transfer for view massively. --- nui/include/nui/window.hpp | 53 ++++ ...mac_webview_config_from_window_options.ipp | 144 ++++++++- nui/src/nui/backend/window_impl_linux.ipp | 279 +++++++++++++++--- nui/src/nui/backend/window_impl_win.ipp | 265 +++++++++++++++-- 4 files changed, 662 insertions(+), 79 deletions(-) diff --git a/nui/include/nui/window.hpp b/nui/include/nui/window.hpp index a1fa6992..8cac281c 100644 --- a/nui/include/nui/window.hpp +++ b/nui/include/nui/window.hpp @@ -59,6 +59,20 @@ namespace Nui { std::string scheme{}; std::variant, std::function> getContent; + /// Pull-based streaming reader for the request body. Only populated when CustomScheme::streamingContent + /// is true; empty (falsy std::function) otherwise. When populated, `getContent` is left empty and must + /// not be invoked — read the body via this reader instead. + /// + /// Fills `buffer` with up to `bufferSize` bytes and returns the number of bytes written. A return value + /// of 0 signals EOF. Partial reads (less than `bufferSize` without EOF) are permitted; call repeatedly + /// until 0 is returned. + /// + /// LIFETIME — IMPORTANT: this reader (and `getContent`) is only guaranteed to be safe to invoke + /// during the synchronous body of `CustomScheme::onRequest`. The backend retains the underlying + /// request handle for the duration of these closures, but invoking them after onRequest has returned + /// (e.g. from an asynchronous `bodyReader`) is undefined behavior. Read all body bytes you need + /// before returning a CustomSchemeResponse. + std::function readContent; std::unordered_multimap headers{}; std::string uri{}; std::string method{}; @@ -69,13 +83,46 @@ namespace Nui NuiCoreWebView2WebResourceContext resourceContext = NuiCoreWebView2WebResourceContext::All; }; + /// Response returned from a CustomScheme::onRequest handler. + /// + /// The body may be supplied in one of three mutually exclusive forms. When more than one is populated, + /// the backend uses the first one in this priority order: + /// + /// 1. `bodyFile` — file path; streamed directly by the OS (best for files, no buffering). + /// 2. `bodyReader` — pull-based callback; streamed chunk-by-chunk (best for generated / piped data). + /// 3. `body` — in-memory string (simplest, but the full payload must fit in memory). + /// + /// Pick exactly one. Setting multiple is not an error, but the lower-priority ones are silently ignored. struct CustomSchemeResponse { int statusCode; /// WINDOWS ONLY std::string reasonPhrase{}; std::unordered_multimap headers{}; + + /// In-memory response body. Simple and zero-ceremony for small payloads, but the entire body must + /// be buffered in memory before the response is emitted — on Windows it is additionally copied into + /// a COM memory stream, so peak memory use is roughly 2× the body size. For payloads larger than a + /// few MB prefer `bodyFile` or `bodyReader`. Ignored when `bodyFile` or `bodyReader` is set. std::string body{}; + + /// Path to a file whose contents are streamed as the response body. Most efficient way to serve + /// files — uses `SHCreateStreamOnFileEx` on Windows, `g_file_read` (GFileInputStream) on Linux, and + /// a chunked `std::ifstream` dispatch on macOS. No intermediate copy of the file contents is made. + /// If the `Content-Length` header is absent, the backend populates it from the file size. + std::optional bodyFile{}; + + /// Pull-based streaming reader for the response body. Use for non-file sources (generated output, + /// sockets, decompressed streams, etc.). Fills `buffer` with up to `bufferSize` bytes. Returns the + /// number of bytes written; a return value of 0 signals EOF. Partial reads (less than `bufferSize` + /// without EOF) are permitted — the backend loops until 0 is returned. If the total size is known, + /// set `Content-Length` in `headers`; otherwise the response will be delivered without it. + /// + /// THREADING: the backend may invoke this from any thread, but never concurrently with itself for + /// the same response. Exceptions thrown from this callback are caught at the platform boundary and + /// converted into a stream-read failure (the response is truncated). Avoid throwing if you can + /// signal "no more bytes" by returning 0 instead. + std::function bodyReader{}; }; struct CustomScheme @@ -94,6 +141,12 @@ namespace Nui /// WINDOWS ONLY - URI contains an authority. like "scheme://AUTHORITY_HERE/path". bool hasAuthorityComponent = false; + + /// When true, the backend populates CustomSchemeRequest::readContent (pull-based streaming reader) + /// instead of CustomSchemeRequest::getContent. Use for large request bodies that should not be buffered + /// into memory in their entirety. Supported fully on Windows and Linux; on macOS the body is already + /// fully buffered by the OS so this only offers API consistency, not memory savings. + bool streamingContent = false; }; struct WindowOptions diff --git a/nui/src/nui/backend/mac_webview_config_from_window_options.ipp b/nui/src/nui/backend/mac_webview_config_from_window_options.ipp index b23d4f5c..4f48b3cb 100644 --- a/nui/src/nui/backend/mac_webview_config_from_window_options.ipp +++ b/nui/src/nui/backend/mac_webview_config_from_window_options.ipp @@ -4,6 +4,19 @@ namespace Nui::MacOs { + // RAII-retained Objective-C reference. Construction sends `retain`, destruction sends `release`. + // shared_ptr aliasing keeps it cheap to copy across captures. + inline std::shared_ptr retainObjC(id obj) + { + if (!obj) + return {}; + msg_send(obj, "retain"_sel); + return std::shared_ptr{obj, [](void* p) noexcept { + if (p) + msg_send(static_cast(p), "release"_sel); + }}; + } + class NuiSchemeHandler { public: @@ -59,9 +72,41 @@ namespace Nui::MacOs return m_scheme; } + static void startURLSchemeTaskImpl(id self, id task); + static void startURLSchemeTask(id self, SEL /*_cmd*/, id /*webView*/, id task) { webview::detail::objc::autoreleasepool pool; + // Exceptions must not cross into the Obj-C runtime. On failure, fail the task explicitly so + // the renderer doesn't hang waiting for didFinish. + try + { + startURLSchemeTaskImpl(self, task); + } + catch (std::exception const& ex) + { + id domain = msg_send("NSString"_cls, "stringWithUTF8String:"_sel, "NuiSchemeHandler"); + id desc = msg_send("NSString"_cls, "stringWithUTF8String:"_sel, ex.what()); + id userInfo = msg_send("NSDictionary"_cls, "dictionaryWithObject:forKey:"_sel, + desc, "NSLocalizedDescription"_str); + id err = msg_send("NSError"_cls, "errorWithDomain:code:userInfo:"_sel, + domain, static_cast(-1), userInfo); + msg_send(task, "didFailWithError:"_sel, err); + } + catch (...) + { + id domain = msg_send("NSString"_cls, "stringWithUTF8String:"_sel, "NuiSchemeHandler"); + id desc = "Unknown C++ exception in custom scheme handler"_str; + id userInfo = msg_send("NSDictionary"_cls, "dictionaryWithObject:forKey:"_sel, + desc, "NSLocalizedDescription"_str); + id err = msg_send("NSError"_cls, "errorWithDomain:code:userInfo:"_sel, + domain, static_cast(-1), userInfo); + msg_send(task, "didFailWithError:"_sel, err); + } + } + + static void startURLSchemeTaskImpl(id self, id task) + { NuiSchemeHandler* handler = reinterpret_cast(self); id request = msg_send(task, "request"_sel); @@ -69,15 +114,40 @@ namespace Nui::MacOs std::string methodStr = msg_send(method, "UTF8String"_sel); id url = msg_send(request, "URL"_sel); + const bool streaming = handler->scheme().streamingContent; + + // Hold a strong ref on the request so body-reading lambdas stay safe even if a user copies + // them out of the synchronous onRequest scope (the API allows it; we don't want UAF when they do). + auto requestRef = retainObjC(request); + CustomSchemeRequest schemeRequest = { .scheme = handler->scheme().scheme, - .getContent = std::function{[request]() { - id body = msg_send(request, "HTTPBody"_sel); + .getContent = streaming ? std::function{} : std::function{[requestRef]() { + id body = msg_send(static_cast(requestRef.get()), "HTTPBody"_sel); if (!body) return std::string{}; - std::string bodyStr = msg_send(body, "UTF8String"_sel); - return bodyStr; + const NSUInteger length = msg_send(body, "length"_sel); + const void* bytes = msg_send(body, "bytes"_sel); + if (!bytes || length == 0) + return std::string{}; + return std::string(static_cast(bytes), static_cast(length)); }}, + .readContent = streaming + ? std::function{[requestRef, offset = std::size_t{0}](char* buffer, std::size_t bufferSize) mutable -> std::size_t { + id body = msg_send(static_cast(requestRef.get()), "HTTPBody"_sel); + if (!body) + return 0; + const NSUInteger totalLength = msg_send(body, "length"_sel); + if (offset >= static_cast(totalLength)) + return 0; + const std::size_t available = static_cast(totalLength) - offset; + const std::size_t toCopy = available < bufferSize ? available : bufferSize; + const NSRange range = {static_cast(offset), static_cast(toCopy)}; + msg_send(body, "getBytes:range:"_sel, buffer, range); + offset += toCopy; + return toCopy; + }} + : std::function{}, .headers = [request]() { std::unordered_multimap headerMap; @@ -104,7 +174,30 @@ namespace Nui::MacOs .method = methodStr, }; - const auto response = handler->scheme().onRequest(schemeRequest); + auto response = handler->scheme().onRequest(schemeRequest); + + // For bodyFile, auto-populate Content-Length from file size when not already provided. + // If the file cannot be opened, surface the failure as 500 instead of silently emitting + // an empty 200 — silent success-with-empty-body is a cache-poisoning footgun. + std::ifstream bodyFileStream; + std::optional bodyFileErrorBody; + if (response.bodyFile) + { + std::error_code ec; + const auto size = std::filesystem::file_size(*response.bodyFile, ec); + if (!ec && response.headers.find("Content-Length") == response.headers.end()) + response.headers.emplace("Content-Length", std::to_string(size)); + bodyFileStream.open(*response.bodyFile, std::ios::binary); + if (!bodyFileStream.is_open()) + { + bodyFileErrorBody = "Internal Server Error: bodyFile could not be opened."; + response.statusCode = 500; + response.headers.erase("Content-Length"); + response.headers.emplace("Content-Length", std::to_string(bodyFileErrorBody->size())); + response.headers.erase("Content-Type"); + response.headers.emplace("Content-Type", "text/plain; charset=utf-8"); + } + } auto headers = msg_send("NSMutableDictionary"_cls, "dictionary"_sel); if (response.headers.find("Access-Control-Allow-Origin") == response.headers.end() && @@ -133,12 +226,41 @@ namespace Nui::MacOs msg_send(task, "didReceiveResponse:"_sel, nsResponse); - auto data = msg_send( - "NSData"_cls, - "dataWithBytes:length:"_sel, - response.body.data(), - static_cast(response.body.size())); - msg_send(task, "didReceiveData:"_sel, data); + // Deliver body in priority order: bodyFile > bodyReader > body. + // WKURLSchemeTask supports multiple didReceiveData: calls, so we stream in chunks. + // Heap-allocate the chunk so we don't put 64 KiB on the (potentially small) dispatch-queue stack. + constexpr std::size_t chunkSize = 64 * 1024; + auto chunk = std::make_unique(chunkSize); + + auto sendChunk = [&](const void* bytes, std::size_t n) { + id data = msg_send( + "NSData"_cls, "dataWithBytes:length:"_sel, bytes, static_cast(n)); + msg_send(task, "didReceiveData:"_sel, data); + }; + + if (bodyFileErrorBody) + { + sendChunk(bodyFileErrorBody->data(), bodyFileErrorBody->size()); + } + else if (response.bodyFile) + { + auto readNext = [&]() -> std::size_t { + bodyFileStream.read(chunk.get(), chunkSize); + return static_cast(bodyFileStream.gcount()); + }; + for (auto n = readNext(); n > 0; n = readNext()) + sendChunk(chunk.get(), n); + } + else if (response.bodyReader) + { + for (auto n = response.bodyReader(chunk.get(), chunkSize); n > 0; + n = response.bodyReader(chunk.get(), chunkSize)) + sendChunk(chunk.get(), n); + } + else + { + sendChunk(response.body.data(), response.body.size()); + } msg_send(task, "didFinish"_sel); } diff --git a/nui/src/nui/backend/window_impl_linux.ipp b/nui/src/nui/backend/window_impl_linux.ipp index 28b2c224..cf94abc5 100644 --- a/nui/src/nui/backend/window_impl_linux.ipp +++ b/nui/src/nui/backend/window_impl_linux.ipp @@ -2,6 +2,83 @@ namespace Nui::Impl::Linux { + using ReaderFn = std::function; + + // GInputStream subclass that pulls bytes from a std::function reader. + // GType demands a POD layout so the ReaderFn lives behind a pointer; ownership is passed via + // unique_ptr in nuiReaderInputStreamNew and re-adopted by unique_ptr in the finalize hook. + struct NuiReaderInputStream + { + GInputStream parent; + ReaderFn* reader; + }; + struct NuiReaderInputStreamClass + { + GInputStreamClass parent_class; + }; + + inline gssize nuiReaderInputStreamRead( + GInputStream* stream, void* buffer, gsize count, GCancellable* /*cancellable*/, GError** error) + { + auto* self = reinterpret_cast(stream); + if (self->reader == nullptr) + return 0; + try + { + return static_cast((*self->reader)(static_cast(buffer), count)); + } + catch (std::exception const& ex) + { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, ex.what()); + return -1; + } + catch (...) + { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, "bodyReader threw a non-std::exception"); + return -1; + } + } + inline gboolean nuiReaderInputStreamClose(GInputStream* /*stream*/, GCancellable* /*cancellable*/, GError** /*error*/) + { + return TRUE; + } + inline void nuiReaderInputStreamFinalize(GObject* obj) + { + auto* self = reinterpret_cast(obj); + std::unique_ptr owned{self->reader}; + self->reader = nullptr; + G_OBJECT_CLASS(g_type_class_peek_parent(G_OBJECT_GET_CLASS(obj)))->finalize(obj); + } + inline void nuiReaderInputStreamClassInit(gpointer klass, gpointer /*classData*/) + { + G_OBJECT_CLASS(klass)->finalize = nuiReaderInputStreamFinalize; + G_INPUT_STREAM_CLASS(klass)->read_fn = nuiReaderInputStreamRead; + G_INPUT_STREAM_CLASS(klass)->close_fn = nuiReaderInputStreamClose; + } + inline void nuiReaderInputStreamInit(GTypeInstance* instance, gpointer /*klass*/) + { + reinterpret_cast(instance)->reader = nullptr; + } + inline GType nuiReaderInputStreamGetType() + { + static GType type = []() { + GTypeInfo info{}; + info.class_size = sizeof(NuiReaderInputStreamClass); + info.class_init = nuiReaderInputStreamClassInit; + info.instance_size = sizeof(NuiReaderInputStream); + info.instance_init = nuiReaderInputStreamInit; + return g_type_register_static( + G_TYPE_INPUT_STREAM, "NuiReaderInputStream", &info, static_cast(0)); + }(); + return type; + } + inline GInputStream* nuiReaderInputStreamNew(ReaderFn reader) + { + auto* self = static_cast(g_object_new(nuiReaderInputStreamGetType(), nullptr)); + self->reader = std::make_unique(std::move(reader)).release(); + return reinterpret_cast(self); + } + struct AsyncResponse { GObjectReference stream; @@ -45,8 +122,39 @@ std::size_t strlenLimited(char const* str, std::size_t limit) return i; } +namespace Nui::Impl::Linux +{ + inline void uriSchemeRequestCallbackImpl(WebKitURISchemeRequest* request, gpointer userData); +} + extern "C" { void uriSchemeRequestCallback(WebKitURISchemeRequest* request, gpointer userData) + { + // Exceptions must not cross the C boundary into glib/webkit. Convert any escape into a + // synthetic finish_error so the renderer is unblocked. + try + { + Nui::Impl::Linux::uriSchemeRequestCallbackImpl(request, userData); + } + catch (std::exception const& ex) + { + GError* error = g_error_new(WEBKIT_DOWNLOAD_ERROR_DESTINATION, 1, "%s", ex.what()); + std::unique_ptr errorOwner{error, &g_error_free}; + webkit_uri_scheme_request_finish_error(request, error); + } + catch (...) + { + GError* error = g_error_new( + WEBKIT_DOWNLOAD_ERROR_DESTINATION, 1, "Unknown C++ exception in custom scheme handler"); + std::unique_ptr errorOwner{error, &g_error_free}; + webkit_uri_scheme_request_finish_error(request, error); + } + } +} + +namespace Nui::Impl::Linux +{ + inline void uriSchemeRequestCallbackImpl(WebKitURISchemeRequest* request, gpointer userData) { using namespace std::string_literals; @@ -77,44 +185,84 @@ extern "C" { cmethod = ""; auto const& schemeInfo = schemeContext->schemeInfo; + const bool streaming = schemeInfo.streamingContent; + + // webkit_uri_scheme_request_get_http_body returns transfer-full; GObjectReference handles the + // unref via RAII. close() is an I/O flush, not memory management, so it stays explicit. + struct LinuxStreamState + { + Nui::GObjectReference stream{}; + bool initialized = false; + ~LinuxStreamState() + { + if (stream) + g_input_stream_close(stream.get(), nullptr, nullptr); + } + }; + auto streamState = std::make_shared(); + + // Hold a ref on the request so the body-reading lambdas stay safe even if a user copies them + // out of the synchronous onRequest scope (the API allows it; we don't want UAF when they do). + auto requestRef = Nui::GObjectReference{request}; + const auto responseObj = schemeInfo.onRequest( Nui::CustomSchemeRequest{ .scheme = schemeInfo.scheme, - .getContent = std::function{[request]() -> std::string { + .getContent = streaming ? std::function{} : std::function{[requestRef]() -> std::string { #if (WEBKIT_MAJOR_VERSION == 2 && WEBKIT_MINOR_VERSION >= 40) || WEBKIT_MAJOR_VERSION > 2 - auto* stream = webkit_uri_scheme_request_get_http_body(request); - if (stream == nullptr) + auto stream = Nui::GObjectReference::adoptReference( + webkit_uri_scheme_request_get_http_body(requestRef.get())); + if (!stream) return std::string{}; - Nui::ScopeExit deleteStream = Nui::ScopeExit{[stream]() noexcept { - g_input_stream_close(stream, nullptr, nullptr); + Nui::ScopeExit closeStream = Nui::ScopeExit{[s = stream.get()]() noexcept { + g_input_stream_close(s, nullptr, nullptr); }}; - // read the ginputstream to string - GDataInputStream* dataInputStream = g_data_input_stream_new(stream); - gsize length; - GError* error = NULL; - gchar* data = g_data_input_stream_read_upto(dataInputStream, "", 0, &length, NULL, &error); + auto dataInputStream = Nui::GObjectReference::adoptReference( + g_data_input_stream_new(stream.get())); + gsize length = 0; + GError* errorRaw = nullptr; + gchar* dataRaw = g_data_input_stream_read_upto(dataInputStream.get(), "", 0, &length, nullptr, &errorRaw); + std::unique_ptr data{dataRaw, &g_free}; + std::unique_ptr error{errorRaw, &g_error_free}; - Nui::ScopeExit freeData = Nui::ScopeExit{[data]() noexcept { - g_free(data); - }}; - Nui::ScopeExit freeError = Nui::ScopeExit{[error]() noexcept { - g_error_free(error); - }}; - - if (error != NULL) - { - freeData.disarm(); + if (error) return {}; - } - - freeError.disarm(); - return std::string(data, length); + if (!data) + return {}; + return std::string(data.get(), length); #else // Not implemented in earlier webkitgtk versions :( return std::string{}; #endif }}, + .readContent = streaming + ? std::function{[requestRef, streamState](char* buffer, std::size_t bufferSize) -> std::size_t { +#if (WEBKIT_MAJOR_VERSION == 2 && WEBKIT_MINOR_VERSION >= 40) || WEBKIT_MAJOR_VERSION > 2 + if (!streamState->initialized) + { + streamState->stream = Nui::GObjectReference::adoptReference( + webkit_uri_scheme_request_get_http_body(requestRef.get())); + streamState->initialized = true; + } + if (!streamState->stream) + return 0; + GError* errorRaw = nullptr; + const gssize bytesRead = + g_input_stream_read(streamState->stream.get(), buffer, bufferSize, nullptr, &errorRaw); + std::unique_ptr error{errorRaw, &g_error_free}; + if (error || bytesRead <= 0) + return 0; + return static_cast(bytesRead); +#else + (void)requestRef; + (void)streamState; + (void)buffer; + (void)bufferSize; + return 0; +#endif + }} + : std::function{}, .headers = [request]() { auto* headers = webkit_uri_scheme_request_get_http_headers(request); @@ -141,19 +289,72 @@ extern "C" { ++schemeContext->asyncResponseCounter; schemeContext->asyncResponses[schemeContext->asyncResponseCounter] = Nui::Impl::Linux::AsyncResponse{}; auto& asyncResponse = schemeContext->asyncResponses[schemeContext->asyncResponseCounter]; - asyncResponse.data = std::move(responseObj.body); - asyncResponse.stream = Nui::GObjectReference::adoptReference(g_memory_input_stream_new_from_data( - asyncResponse.data.data(), static_cast(asyncResponse.data.size()), nullptr)); + // Move headers aside so we can inject a Content-Length for bodyFile without mutating the original. + auto responseHeaders = std::move(responseObj.headers); + gint64 contentLength = -1; // -1 = unknown (chunked); webkit_uri_scheme_response_new accepts this + int statusCode = responseObj.statusCode; - asyncResponse.response = Nui::GObjectReference::adoptReference( - webkit_uri_scheme_response_new(asyncResponse.stream.get(), static_cast(asyncResponse.data.size()))); + // Resolve body source in priority order: bodyFile > bodyReader > body. + if (responseObj.bodyFile) + { + const auto& path = *responseObj.bodyFile; + auto file = GObjectReference::adoptReference(g_file_new_for_path(path.c_str())); + GError* errorRaw = nullptr; + GFileInputStream* fileStream = g_file_read(file.get(), nullptr, &errorRaw); + std::unique_ptr error{errorRaw, &g_error_free}; + + if (fileStream != nullptr) + { + asyncResponse.stream = GObjectReference::adoptReference(G_INPUT_STREAM(fileStream)); + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + if (!ec) + { + contentLength = static_cast(size); + if (responseHeaders.find("Content-Length") == responseHeaders.end()) + responseHeaders.emplace("Content-Length", std::to_string(size)); + } + } + else + { + // Surface the failure as 500 instead of silently emitting an empty 200 — silent + // success-with-empty-body is a cache-poisoning footgun. + statusCode = 500; + asyncResponse.data = "Internal Server Error: bodyFile could not be opened."; + asyncResponse.stream = + GObjectReference::adoptReference(g_memory_input_stream_new_from_data( + asyncResponse.data.data(), static_cast(asyncResponse.data.size()), nullptr)); + contentLength = static_cast(asyncResponse.data.size()); + responseHeaders.erase("Content-Length"); + responseHeaders.emplace("Content-Length", std::to_string(asyncResponse.data.size())); + responseHeaders.erase("Content-Type"); + responseHeaders.emplace("Content-Type", "text/plain; charset=utf-8"); + } + } + else if (responseObj.bodyReader) + { + asyncResponse.stream = GObjectReference::adoptReference( + Nui::Impl::Linux::nuiReaderInputStreamNew(std::move(responseObj.bodyReader))); + // length unknown → -1 (chunked) + } + else + { + asyncResponse.data = std::move(responseObj.body); + asyncResponse.stream = + GObjectReference::adoptReference(g_memory_input_stream_new_from_data( + asyncResponse.data.data(), static_cast(asyncResponse.data.size()), nullptr)); + contentLength = static_cast(asyncResponse.data.size()); + } + + asyncResponse.response = GObjectReference::adoptReference( + webkit_uri_scheme_response_new(asyncResponse.stream.get(), contentLength)); const std::string contentType = [&]() { - if (responseObj.headers.find("Content-Type") != responseObj.headers.end()) + if (responseHeaders.find("Content-Type") != responseHeaders.end()) { std::string contentType; - auto range = responseObj.headers.equal_range("Content-Type"); + auto range = responseHeaders.equal_range("Content-Type"); for (auto it = range.first; it != range.second; ++it) contentType += it->second + "; "; contentType.pop_back(); @@ -165,26 +366,28 @@ extern "C" { webkit_uri_scheme_response_set_content_type(asyncResponse.response.get(), contentType.c_str()); webkit_uri_scheme_response_set_status( - asyncResponse.response.get(), static_cast(responseObj.statusCode), nullptr); + asyncResponse.response.get(), static_cast(statusCode), nullptr); auto setHeaders = [&]() { - auto* responseHeaders = soup_message_headers_new(SOUP_MESSAGE_HEADERS_RESPONSE); - for (auto const& [key, value] : responseObj.headers) - soup_message_headers_append(responseHeaders, key.c_str(), value.c_str()); + auto* responseHeadersObj = soup_message_headers_new(SOUP_MESSAGE_HEADERS_RESPONSE); + for (auto const& [key, value] : responseHeaders) + soup_message_headers_append(responseHeadersObj, key.c_str(), value.c_str()); - if (responseObj.headers.find("Access-Control-Allow-Origin") == responseObj.headers.end() && + if (responseHeaders.find("Access-Control-Allow-Origin") == responseHeaders.end() && !schemeInfo.allowedOrigins.empty()) { auto const& front = schemeInfo.allowedOrigins.front(); - soup_message_headers_append(responseHeaders, "Access-Control-Allow-Origin", front.c_str()); + soup_message_headers_append(responseHeadersObj, "Access-Control-Allow-Origin", front.c_str()); } - webkit_uri_scheme_response_set_http_headers(asyncResponse.response.get(), responseHeaders); + webkit_uri_scheme_response_set_http_headers(asyncResponse.response.get(), responseHeadersObj); }; setHeaders(); webkit_uri_scheme_request_finish_with_response(request, asyncResponse.response.get()); } +} +extern "C" { void uriSchemeDestroyNotify(void*) { // Useless, because called when everything is already destroyed diff --git a/nui/src/nui/backend/window_impl_win.ipp b/nui/src/nui/backend/window_impl_win.ipp index 546f8fa4..0e9c0e9f 100644 --- a/nui/src/nui/backend/window_impl_win.ipp +++ b/nui/src/nui/backend/window_impl_win.ipp @@ -1,6 +1,128 @@ // ##################################################################################################################### namespace Nui { + namespace Impl::Win + { + // Minimal IStream implementation that pulls bytes from a std::function reader. Only Read / basic + // IUnknown bookkeeping / Stat are implemented — WebView2 doesn't seek response streams. + class ReaderBackedStream : public IStream + { + public: + explicit ReaderBackedStream(std::function reader) + : reader_{std::move(reader)} + , refCount_{1} + {} + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override + { + if (ppv == nullptr) + return E_POINTER; + if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, IID_ISequentialStream) || + IsEqualIID(riid, IID_IStream)) + { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + *ppv = nullptr; + return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override + { + return static_cast(++refCount_); + } + ULONG STDMETHODCALLTYPE Release() override + { + const auto c = --refCount_; + if (c == 0) + delete this; + return static_cast(c); + } + + HRESULT STDMETHODCALLTYPE Read(void* pv, ULONG cb, ULONG* pcbRead) override + { + if (pv == nullptr) + return STG_E_INVALIDPOINTER; + if (cb == 0) + { + if (pcbRead != nullptr) + *pcbRead = 0; + return S_OK; + } + std::size_t n = 0; + if (reader_) + { + try + { + n = reader_(static_cast(pv), static_cast(cb)); + } + catch (...) + { + if (pcbRead != nullptr) + *pcbRead = 0; + return E_FAIL; + } + } + if (pcbRead != nullptr) + *pcbRead = static_cast(n); + // Per IStream contract: S_FALSE signals end-of-stream (zero bytes available). A short read + // (n > 0 && n < cb) is permitted by the bodyReader API and must NOT signal EOF — return S_OK. + return (n == 0) ? S_FALSE : S_OK; + } + HRESULT STDMETHODCALLTYPE Write(void const*, ULONG, ULONG*) override + { + return STG_E_ACCESSDENIED; + } + + HRESULT STDMETHODCALLTYPE Seek(LARGE_INTEGER, DWORD, ULARGE_INTEGER*) override + { + return E_NOTIMPL; + } + HRESULT STDMETHODCALLTYPE SetSize(ULARGE_INTEGER) override + { + return E_NOTIMPL; + } + HRESULT STDMETHODCALLTYPE CopyTo(IStream*, ULARGE_INTEGER, ULARGE_INTEGER*, ULARGE_INTEGER*) override + { + return E_NOTIMPL; + } + HRESULT STDMETHODCALLTYPE Commit(DWORD) override + { + return S_OK; + } + HRESULT STDMETHODCALLTYPE Revert() override + { + return E_NOTIMPL; + } + HRESULT STDMETHODCALLTYPE LockRegion(ULARGE_INTEGER, ULARGE_INTEGER, DWORD) override + { + return STG_E_INVALIDFUNCTION; + } + HRESULT STDMETHODCALLTYPE UnlockRegion(ULARGE_INTEGER, ULARGE_INTEGER, DWORD) override + { + return STG_E_INVALIDFUNCTION; + } + HRESULT STDMETHODCALLTYPE Stat(STATSTG* pstatstg, DWORD /*grfStatFlag*/) override + { + if (pstatstg == nullptr) + return STG_E_INVALIDPOINTER; + ZeroMemory(pstatstg, sizeof(STATSTG)); + pstatstg->type = STGTY_STREAM; + pstatstg->cbSize.QuadPart = 0; // unknown + return S_OK; + } + HRESULT STDMETHODCALLTYPE Clone(IStream**) override + { + return E_NOTIMPL; + } + + private: + virtual ~ReaderBackedStream() = default; + + std::function reader_; + std::atomic refCount_; + }; + } // namespace Impl::Win // ##################################################################################################################### struct Window::WindowsImplementation : public Window::Implementation { @@ -57,7 +179,15 @@ namespace Nui Microsoft::WRL::Callback( [this, schemes = options.customSchemes]( ICoreWebView2* view, ICoreWebView2WebResourceRequestedEventArgs* args) -> HRESULT { - return onSchemeRequest(schemes, view, args); + // Exceptions must not cross the COM ABI back into WebView2. + try + { + return onSchemeRequest(schemes, view, args); + } + catch (...) + { + return E_FAIL; + } }) .Get(), &schemeHandlerToken); @@ -133,8 +263,57 @@ namespace Nui if (result != S_OK) return {}; + // Resolve body source in priority order: bodyFile > bodyReader > body. + // For bodyFile, auto-populate Content-Length from file size when not already provided. + auto headers = responseData.headers; + int statusCode = responseData.statusCode; + std::string phraseUtf8 = responseData.reasonPhrase; + Microsoft::WRL::ComPtr stream; + if (responseData.bodyFile) + { + const auto& path = *responseData.bodyFile; + const auto pathW = path.wstring(); + const HRESULT fileHr = SHCreateStreamOnFileEx( + pathW.c_str(), STGM_READ | STGM_SHARE_DENY_WRITE, FILE_ATTRIBUTE_NORMAL, FALSE, nullptr, &stream); + if (SUCCEEDED(fileHr)) + { + if (headers.find("Content-Length") == headers.end()) + { + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + if (!ec) + headers.emplace("Content-Length", std::to_string(size)); + } + } + else + { + // Surface the failure as 500 instead of silently emitting an empty 200 — silent + // success-with-empty-body is a cache-poisoning footgun. + static constexpr char errorBody[] = "Internal Server Error: bodyFile could not be opened."; + statusCode = 500; + if (phraseUtf8.empty()) + phraseUtf8 = "Internal Server Error"; + stream.Attach( + SHCreateMemStream(reinterpret_cast(errorBody), sizeof(errorBody) - 1)); + headers.erase("Content-Length"); + headers.emplace("Content-Length", std::to_string(sizeof(errorBody) - 1)); + headers.erase("Content-Type"); + headers.emplace("Content-Type", "text/plain; charset=utf-8"); + } + } + else if (responseData.bodyReader) + { + stream.Attach(new Impl::Win::ReaderBackedStream{responseData.bodyReader}); + } + else + { + stream.Attach(SHCreateMemStream( + reinterpret_cast(responseData.body.data()), + static_cast(responseData.body.size()))); + } + std::wstring responseHeaders; - for (auto const& [key, value] : responseData.headers) + for (auto const& [key, value] : headers) responseHeaders += utf8ToUtf16(key) + L": " + utf8ToUtf16(value) + L"\r\n"; if (!responseHeaders.empty()) @@ -143,13 +322,9 @@ namespace Nui responseHeaders.pop_back(); } - Microsoft::WRL::ComPtr stream; - stream.Attach(SHCreateMemStream( - reinterpret_cast(responseData.body.data()), static_cast(responseData.body.size()))); - - const auto phrase = utf8ToUtf16(responseData.reasonPhrase); + const auto phrase = utf8ToUtf16(phraseUtf8); result = environment->CreateWebResourceResponse( - stream.Get(), responseData.statusCode, phrase.c_str(), responseHeaders.c_str(), &response); + stream.Get(), statusCode, phrase.c_str(), responseHeaders.c_str(), &response); return response; } @@ -160,30 +335,60 @@ namespace Nui COREWEBVIEW2_WEB_RESOURCE_CONTEXT resourceContext, ICoreWebView2WebResourceRequest* webViewRequest) { + const bool streaming = customScheme.streamingContent; + + // Hold a ref on the request so body-reading lambdas stay safe even if a user copies them out + // of the synchronous onRequest scope (the API allows it; we don't want UAF when they do). + Microsoft::WRL::ComPtr requestRef{webViewRequest}; + + using GetContentVariant = + std::variant, std::function>; + + auto getContentStreaming = GetContentVariant{std::function{}}; + auto getContentBuffered = GetContentVariant{std::function{ + [requestRef, contentMemo = std::string{}]() mutable -> std::string const& { + if (!contentMemo.empty()) + return contentMemo; + + Microsoft::WRL::ComPtr stream; + requestRef->get_Content(&stream); + + if (!stream) + return contentMemo; + + constexpr ULONG bufferSize = 16 * 1024; + std::array buffer; + ULONG bytesRead = 0; + do + { + stream->Read(buffer.data(), bufferSize, &bytesRead); + contentMemo.append(buffer.data(), bytesRead); + } while (bytesRead == bufferSize); + return contentMemo; + }}}; + + auto readContentStreaming = std::function{ + [requestRef, stream = Microsoft::WRL::ComPtr{}, initialized = false]( + char* buffer, std::size_t bufferSize) mutable -> std::size_t { + if (!initialized) + { + requestRef->get_Content(&stream); + initialized = true; + } + if (!stream) + return 0; + ULONG bytesRead = 0; + const HRESULT hr = stream->Read(buffer, static_cast(bufferSize), &bytesRead); + if (FAILED(hr)) + return 0; + return static_cast(bytesRead); + }}; + return CustomSchemeRequest{ .scheme = customScheme.scheme, - .getContent = - std::function{ - [webViewRequest, contentMemo = std::string{}]() mutable -> std::string const& { - if (!contentMemo.empty()) - return contentMemo; - - Microsoft::WRL::ComPtr stream; - webViewRequest->get_Content(&stream); - - if (!stream) - return contentMemo; - - // FIXME: Dont read the whole thing into memory, if possible via streaming. - ULONG bytesRead = 0; - do - { - std::array buffer; - stream->Read(buffer.data(), 1024, &bytesRead); - contentMemo.append(buffer.data(), bytesRead); - } while (bytesRead == 1024); - return contentMemo; - }}, + .getContent = streaming ? std::move(getContentStreaming) : std::move(getContentBuffered), + .readContent = streaming ? std::move(readContentStreaming) + : std::function{}, .headers = [webViewRequest]() { ICoreWebView2HttpRequestHeaders* headers; From 825e26062f5e16a510ead8fabae842a946e6cb98 Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 19 Apr 2026 00:42:52 +0200 Subject: [PATCH 3/4] Fixed tmpnam warning. --- tools/inline_parser/test/temp_dir.cpp | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tools/inline_parser/test/temp_dir.cpp b/tools/inline_parser/test/temp_dir.cpp index 98f980d1..28da9e17 100644 --- a/tools/inline_parser/test/temp_dir.cpp +++ b/tools/inline_parser/test/temp_dir.cpp @@ -1,12 +1,26 @@ #include "temp_dir.hpp" -#include +#include +#include +#include +#include +#include TempDir::TempDir() : path_{[]() { - auto path = std::tmpnam(nullptr); - std::filesystem::create_directory(path); - return path; + constexpr int maxAttempts = 32; + std::mt19937_64 rng{static_cast( + std::chrono::steady_clock::now().time_since_epoch().count())}; + for (int attempt = 0; attempt < maxAttempts; ++attempt) + { + std::ostringstream name; + name << "nui_inline_parser_test_" << std::hex << rng(); + auto candidate = std::filesystem::temp_directory_path() / name.str(); + std::error_code errorCode; + if (std::filesystem::create_directory(candidate, errorCode)) + return candidate; + } + throw std::runtime_error{"TempDir: failed to create unique directory"}; }()} {} TempDir::~TempDir() From 7f29d8a10692459d5f4a160707ad016e69a1d8de Mon Sep 17 00:00:00 2001 From: 5cript Date: Sun, 19 Apr 2026 00:43:06 +0200 Subject: [PATCH 4/4] Fixed mac build issues. --- .../backend/mac_webview_config_from_window_options.ipp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nui/src/nui/backend/mac_webview_config_from_window_options.ipp b/nui/src/nui/backend/mac_webview_config_from_window_options.ipp index 4f48b3cb..2b6b01e8 100644 --- a/nui/src/nui/backend/mac_webview_config_from_window_options.ipp +++ b/nui/src/nui/backend/mac_webview_config_from_window_options.ipp @@ -72,8 +72,6 @@ namespace Nui::MacOs return m_scheme; } - static void startURLSchemeTaskImpl(id self, id task); - static void startURLSchemeTask(id self, SEL /*_cmd*/, id /*webView*/, id task) { webview::detail::objc::autoreleasepool pool; @@ -142,7 +140,12 @@ namespace Nui::MacOs return 0; const std::size_t available = static_cast(totalLength) - offset; const std::size_t toCopy = available < bufferSize ? available : bufferSize; - const NSRange range = {static_cast(offset), static_cast(toCopy)}; + struct NuiNSRange + { + NSUInteger location; + NSUInteger length; + }; + const NuiNSRange range = {static_cast(offset), static_cast(toCopy)}; msg_send(body, "getBytes:range:"_sel, buffer, range); offset += toCopy; return toCopy;