Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion nui/include/nui/frontend/api/throttle.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include <cstdint>
#include <functional>

#include <nui/frontend/rpc_client.hpp>
Expand Down Expand Up @@ -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<void()> toWrap,
std::function<void(ThrottledFunction&&)> callback,
bool callWhenReady = false);
Expand Down
4 changes: 2 additions & 2 deletions nui/include/nui/frontend/api/timer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<void()> toWrap, std::function<void(TimerHandle&&)> callback);
void setInterval(std::int32_t milliseconds, std::function<void()> toWrap, std::function<void(TimerHandle&&)> callback);

/**
* @brief Creates a new delayed function that calls "toWrap" after "milliseconds" milliseconds.
Expand All @@ -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<void()> toWrap, std::function<void(TimerHandle)> callback);
void setTimeout(std::int32_t milliseconds, std::function<void()> toWrap, std::function<void(TimerHandle)> callback);
}
14 changes: 7 additions & 7 deletions nui/include/nui/frontend/filesystem/file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,20 @@ namespace Nui
public:
~AsyncFile();

void tellg(std::function<void(int32_t)> cb) const;
void tellp(std::function<void(int32_t)> cb) const;
void seekg(int32_t pos, std::function<void()> cb, std::ios_base::seekdir dir = std::ios_base::beg);
void seekp(int32_t pos, std::function<void()> cb, std::ios_base::seekdir dir = std::ios_base::beg);
void tellg(std::function<void(std::int64_t)> cb) const;
void tellp(std::function<void(std::int64_t)> cb) const;
void seekg(std::int64_t pos, std::function<void()> cb, std::ios_base::seekdir dir = std::ios_base::beg);
void seekp(std::int64_t pos, std::function<void()> cb, std::ios_base::seekdir dir = std::ios_base::beg);

void read(int32_t size, std::function<void(std::string&&)> cb);
void read(std::int32_t size, std::function<void(std::string&&)> cb);
void readAll(std::function<void(std::string&&)> cb);
void write(std::string const& data, std::function<void()> cb);

private:
AsyncFile(int32_t id);
AsyncFile(std::int32_t id);

private:
int32_t fileId_;
std::int32_t fileId_;
};
void openFile(
char const* filename,
Expand Down
2 changes: 1 addition & 1 deletion nui/include/nui/shared/api/fetch_options.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 57 additions & 3 deletions nui/include/nui/window.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# include <filesystem>
#endif

#include <cstdint>
#include <memory>
#include <optional>
#include <string>
Expand All @@ -16,7 +17,7 @@

namespace Nui
{
enum class WebViewHint : int
enum class WebViewHint : std::int32_t
{
WEBVIEW_HINT_NONE,
WEBVIEW_HINT_MIN,
Expand Down Expand Up @@ -58,6 +59,20 @@ namespace Nui
{
std::string scheme{};
std::variant<std::function<std::string const&()>, std::function<std::string()>> 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<std::size_t(char* buffer, std::size_t bufferSize)> readContent;
std::unordered_multimap<std::string, std::string> headers{};
std::string uri{};
std::string method{};
Expand All @@ -68,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<std::string, std::string> 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<std::filesystem::path> 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<std::size_t(char* buffer, std::size_t bufferSize)> bodyReader{};
};

struct CustomScheme
Expand All @@ -93,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
Expand Down Expand Up @@ -198,7 +252,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
Expand All @@ -207,7 +261,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.
Expand Down
147 changes: 136 additions & 11 deletions nui/src/nui/backend/mac_webview_config_from_window_options.ipp
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> retainObjC(id obj)
{
if (!obj)
return {};
msg_send<id>(obj, "retain"_sel);
return std::shared_ptr<void>{obj, [](void* p) noexcept {
if (p)
msg_send<void>(static_cast<id>(p), "release"_sel);
}};
}

class NuiSchemeHandler
{
public:
Expand Down Expand Up @@ -62,22 +75,82 @@ namespace Nui::MacOs
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<id>("NSString"_cls, "stringWithUTF8String:"_sel, "NuiSchemeHandler");
id desc = msg_send<id>("NSString"_cls, "stringWithUTF8String:"_sel, ex.what());
id userInfo = msg_send<id>("NSDictionary"_cls, "dictionaryWithObject:forKey:"_sel,
desc, "NSLocalizedDescription"_str);
id err = msg_send<id>("NSError"_cls, "errorWithDomain:code:userInfo:"_sel,
domain, static_cast<NSInteger>(-1), userInfo);
msg_send<void>(task, "didFailWithError:"_sel, err);
}
catch (...)
{
id domain = msg_send<id>("NSString"_cls, "stringWithUTF8String:"_sel, "NuiSchemeHandler");
id desc = "Unknown C++ exception in custom scheme handler"_str;
id userInfo = msg_send<id>("NSDictionary"_cls, "dictionaryWithObject:forKey:"_sel,
desc, "NSLocalizedDescription"_str);
id err = msg_send<id>("NSError"_cls, "errorWithDomain:code:userInfo:"_sel,
domain, static_cast<NSInteger>(-1), userInfo);
msg_send<void>(task, "didFailWithError:"_sel, err);
}
}

static void startURLSchemeTaskImpl(id self, id task)
{
NuiSchemeHandler* handler = reinterpret_cast<NuiSchemeHandler*>(self);

id request = msg_send<id>(task, "request"_sel);
id method = msg_send<id>(request, "HTTPMethod"_sel);
std::string methodStr = msg_send<const char*>(method, "UTF8String"_sel);
id url = msg_send<id>(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<std::string()>{[request]() {
id body = msg_send<id>(request, "HTTPBody"_sel);
.getContent = streaming ? std::function<std::string()>{} : std::function<std::string()>{[requestRef]() {
id body = msg_send<id>(static_cast<id>(requestRef.get()), "HTTPBody"_sel);
if (!body)
return std::string{};
std::string bodyStr = msg_send<const char*>(body, "UTF8String"_sel);
return bodyStr;
const NSUInteger length = msg_send<NSUInteger>(body, "length"_sel);
const void* bytes = msg_send<const void*>(body, "bytes"_sel);
if (!bytes || length == 0)
return std::string{};
return std::string(static_cast<const char*>(bytes), static_cast<std::size_t>(length));
}},
.readContent = streaming
? std::function<std::size_t(char*, std::size_t)>{[requestRef, offset = std::size_t{0}](char* buffer, std::size_t bufferSize) mutable -> std::size_t {
id body = msg_send<id>(static_cast<id>(requestRef.get()), "HTTPBody"_sel);
if (!body)
return 0;
const NSUInteger totalLength = msg_send<NSUInteger>(body, "length"_sel);
if (offset >= static_cast<std::size_t>(totalLength))
return 0;
const std::size_t available = static_cast<std::size_t>(totalLength) - offset;
const std::size_t toCopy = available < bufferSize ? available : bufferSize;
struct NuiNSRange
{
NSUInteger location;
NSUInteger length;
};
const NuiNSRange range = {static_cast<NSUInteger>(offset), static_cast<NSUInteger>(toCopy)};
msg_send<void>(body, "getBytes:range:"_sel, buffer, range);
offset += toCopy;
return toCopy;
}}
: std::function<std::size_t(char*, std::size_t)>{},
.headers =
[request]() {
std::unordered_multimap<std::string, std::string> headerMap;
Expand All @@ -104,7 +177,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<std::string> 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<id>("NSMutableDictionary"_cls, "dictionary"_sel);
if (response.headers.find("Access-Control-Allow-Origin") == response.headers.end() &&
Expand Down Expand Up @@ -133,12 +229,41 @@ namespace Nui::MacOs

msg_send<void>(task, "didReceiveResponse:"_sel, nsResponse);

auto data = msg_send<id>(
"NSData"_cls,
"dataWithBytes:length:"_sel,
response.body.data(),
static_cast<NSUInteger>(response.body.size()));
msg_send<void>(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<char[]>(chunkSize);

auto sendChunk = [&](const void* bytes, std::size_t n) {
id data = msg_send<id>(
"NSData"_cls, "dataWithBytes:length:"_sel, bytes, static_cast<NSUInteger>(n));
msg_send<void>(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<std::size_t>(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<void>(task, "didFinish"_sel);
}

Expand Down
Loading
Loading