Platform Handlers in C
Configure custom platform handlers for file loading, font management, clipboard, and logging in C.
On this page
You can customize how the renderer loads files, resolves fonts, interacts with the clipboard, and outputs audio by configuring platform handlers in C. Each platform handler is a C struct of function pointers that you populate and pass to a ulPlatformSet*() setter function.
Read Setting Up the Platform first to learn which handlers the library requires and the order to install them. All handlers follow the shared memory and callback conventions in C API Conventions. The GPU driver uses a dedicated interface covered in GPU Driver in C.
Differences from C++
The C API replaces C++ handler classes with structs of function pointers.
| C++ Feature | C API Equivalent | What Changes |
|---|---|---|
Subclass FileSystem and call Platform::instance().set_file_system() |
Populate ULFileSystem and call ulPlatformSetFileSystem() |
The library copies the struct. |
| You own the handler object | The library owns its copy of the struct | Callback functions must stay valid until the Renderer or App is destroyed. |
Access state through this |
user_data on three structs only |
Handlers without user_data share file-scope state (see the warning below). |
Return RefPtr<Buffer>, RefPtr<FontFile>, RefPtr<ClipboardData>, or String |
Return an owned handle | The library consumes and destroys the returned handle. |
Stock handlers from <AppCore/Platform.h> |
ulEnable*() functions from <AppCore/CAPI.h> |
Call one function per stock handler. |
#include <Ultralight/platform/AudioOutput.h> |
Nothing extra to include | Keep the #if UL_HAS(MEDIA) guard. |
Installing Handlers
Populate each handler struct and call its setter before creating the Renderer— you can mix custom handlers and AppCore's stock handlers freely.
#include <AppCore/CAPI.h>
void InstallFileSystem(void); // our ULFileSystem (see Serving Files)
ULRenderer CreateRenderer(void) {
InstallFileSystem();
ulEnablePlatformFontLoader();
ulEnablePlatformClipboard();
#if UL_HAS(MEDIA)
ulEnablePlatformAudioOutput();
#endif
ULConfig config = ulCreateConfig();
ULRenderer renderer = ulCreateRenderer(config);
ulDestroyConfig(config);
return renderer;
}
Stock ulEnable*() functions ship in AppCore, so you must link against the AppCore library to use them. The C tabs in Setting Up the Platform show how to enable the platform font loader, file system, and logger.
When you call ulCreateApp(), the library automatically installs stock handlers for any handler you haven't set. For available configuration options, see Renderer in C.
Handler Structs and Setters
Every platform interface has a corresponding C struct and setter function.
| Handler Struct | Setter Function | C++ Guide |
|---|---|---|
ULFileSystem |
ulPlatformSetFileSystem() |
Custom File System |
ULFontLoader |
ulPlatformSetFontLoader() |
Custom Font Loading |
ULClipboard |
ulPlatformSetClipboard() |
Clipboard Integration |
ULLogger |
ulPlatformSetLogger() |
Logging and Console Messages |
ULSurfaceDefinition |
ulPlatformSetSurfaceDefinition() |
Render Surfaces |
ULThreadFactory |
ulPlatformSetThreadFactory() |
Custom Threads |
ULAudioOutput |
ulPlatformSetAudioOutput() |
Media and Audio (Pro edition and up) |
ULProfiler |
ulPlatformSetProfiler() |
Profiling and Tracing (Pro edition and up) |
ULGPUDriver |
ulPlatformSetGPUDriver() |
GPU Driver in C |
Struct Lifetimes and Initialization
Keep these rules in mind when setting up and managing handler structs:
- Setters copy the struct. The library copies the struct by value, so you can pass a local variable.
- Callbacks and user data must stay valid. Keep your function pointers and any
user_datapointer valid until the Renderer or App is destroyed. - The library never frees user data. Platform handler structs don't provide a
destroy_user_datahook, so you're responsible for cleaning up your state— see C API Conventions. - Zero-initialize handler structs. Use designated initializers or
= {0}so omitted fields default toNULLrather than garbage pointers. Optional callbacks set toNULLfall back safely.
📘 Set Handlers Before Creating the Renderer
Set each handler once before calling
ulCreateRenderer()orulCreateApp(). Replacing a handler later deletes the previous handler, which the library may still be using.
🚧 Guard Shared State Across Threads
ULFileSystem,ULFontLoader, andULLoggerdon't provide auser_datapointer. Their callbacks run concurrently on multiple threads (including file loaders, Web Workers, and logging threads). Guard any file-scope state they share with a lock.
Returning Handles from Callbacks
The library takes ownership of every handle your callbacks return and destroys it when finished— never destroy a handle after returning it. Any argument passed into your callback is borrowed and stays valid only for the duration of the call. For full ownership rules, see C API Conventions.
| Callback | Return Type | What NULL Means |
|---|---|---|
open_file |
ULBuffer |
The file cannot be opened. |
load |
ULFontFile |
The font cannot be loaded, and the renderer falls back to another font. |
read (clipboard) |
ULClipboardData |
The clipboard is empty. |
get_file_mime_type, get_file_charset, get_fallback_font, get_fallback_font_for_characters |
ULString |
Never return NULL— return "application/unknown" or "utf-8" when unsure. |
Serving Files
Implement the four callbacks in ULFileSystem to serve page assets and renderer resources from memory or your own asset pipeline.
#include <Ultralight/CAPI.h>
static bool OnFileExists(ULString path) {
(void)path;
// Pseudo-code, look ulStringGetData(path) up in your asset store here.
return false;
}
static ULString OnGetFileMimeType(ULString path) {
(void)path;
// Pseudo-code, pick the MIME type from the path's extension here.
return ulCreateString("application/unknown");
}
static ULString OnGetFileCharset(ULString path) {
(void)path;
return ulCreateString("utf-8");
}
static void OnReleaseAsset(void* user_data, void* data) {
(void)user_data;
(void)data;
// Pseudo-code, unmap the asset's bytes here (this can run on any thread).
}
static ULBuffer OnOpenFile(ULString path) {
(void)path;
void* data = NULL;
size_t size = 0;
// Pseudo-code, map ulStringGetData(path) from your asset store here.
if (!data)
return NULL;
return ulCreateBuffer(data, size, NULL, OnReleaseAsset);
}
void InstallFileSystem(void) {
ULFileSystem file_system = {
.file_exists = OnFileExists,
.get_file_mime_type = OnGetFileMimeType,
.get_file_charset = OnGetFileCharset,
.open_file = OnOpenFile,
};
ulPlatformSetFileSystem(file_system);
}
Calling ulCreateBuffer() wraps existing memory without copying data. The destruction callback you pass to ulCreateBuffer() runs when the library releases the buffer, which can happen later and on another thread.
If source memory won't stay valid until the buffer is released (such as when reusing a scratch block), call ulCreateBufferFromCopy() instead so the library manages its own copy.
For details on MIME types, 16-byte alignment, and bundling the SDK's resources/ folder, see Custom File System.
Loading Fonts
Implement ULFontLoader to resolve font descriptions to font files on disk or in memory.
#include <Ultralight/CAPI.h>
#include <string.h>
static ULString OnGetFallbackFont(void) {
return ulCreateString("Rocket Sans");
}
static ULString OnGetFallbackFontForCharacters(ULString characters,
int weight, bool italic) {
(void)characters;
(void)weight;
(void)italic;
return ulCreateString("Rocket CJK");
}
static ULFontFile OnLoadFont(ULString family, int weight, bool italic) {
(void)weight;
(void)italic;
const char* name = ulStringGetData(family);
if (strcmp(name, "Rocket Sans") == 0) {
const void* bytes = NULL;
size_t size = 0;
// Pseudo-code, fetch the TTF bytes for this weight here.
ULBuffer buffer = ulCreateBufferFromCopy(bytes, size);
ULFontFile font = ulFontFileCreateFromBuffer(buffer);
ulDestroyBuffer(buffer); // the font file holds its own reference
return font;
}
if (strcmp(name, "Rocket CJK") == 0) {
ULString path = ulCreateString("fonts/rocket-cjk.ttf");
ULFontFile font = ulFontFileCreateFromFilePath(path);
ulDestroyString(path);
return font;
}
return NULL;
}
void InstallFontLoader(void) {
ULFontLoader font_loader = {
.get_fallback_font = OnGetFallbackFont,
.get_fallback_font_for_characters = OnGetFallbackFontForCharacters,
.load = OnLoadFont,
};
ulPlatformSetFontLoader(font_loader);
}
🚧 Destroy Font Buffers but Not Font Files
When creating a font file with
ulFontFileCreateFromBuffer(), destroy yourULBufferhandle immediately— the font file retains its own reference, so skippingulDestroyBuffer()leaks the buffer handle. Never destroy the returnedULFontFilehandle, because the library takes ownership and frees it.
ulFontFileCreateFromFilePath() takes an OS file path directly. The renderer does not route this path through your ULFileSystem.
For font collection files (such as .ttc files), call ulFontFileCreateFromBufferWithFaceIndex() or ulFontFileCreateFromFilePathWithFaceIndex(). Pass -1 for the face index to let the library pick the best match for the requested weight and style.
For font fallback rules and character coverage requirements, see Custom Font Loading.
Clipboard Integration
Implement ULClipboard to handle clipboard operations with the OS.
#include <Ultralight/CAPI.h>
#include <string.h>
static void OnClipboardClear(void* user_data) {
(void)user_data;
// Pseudo-code, empty the OS clipboard here.
}
static ULClipboardData OnClipboardRead(void* user_data) {
(void)user_data;
// Pseudo-code, fetch the OS clipboard's text here ("" when empty).
const char* os_text = "";
if (os_text[0] == '\0')
return NULL;
ULString text = ulCreateString(os_text);
ULClipboardData data = ulCreateClipboardDataFromText(text);
ulDestroyString(text);
return data;
}
static void OnClipboardWrite(void* user_data, ULClipboardData data) {
(void)user_data;
// Pseudo-code, open and empty the OS clipboard here.
size_t count = ulClipboardDataGetSize(data);
for (size_t i = 0; i < count; ++i) {
ULString type = ulClipboardDataGetTypeAt(data, i);
ULString text = ulClipboardDataGetText(data, type); // NULL for bytes
if (text && strcmp(ulStringGetData(type), "text/plain") == 0) {
// Pseudo-code, write ulStringGetData(text) as OS text here.
}
if (text)
ulDestroyString(text);
ulDestroyString(type);
}
// Pseudo-code, close the OS clipboard here.
}
void InstallClipboard(void* my_context) {
ULClipboard clipboard = {
.user_data = my_context,
.clear = OnClipboardClear,
.read = OnClipboardRead,
.write = OnClipboardWrite,
};
ulPlatformSetClipboard(clipboard);
}
Follow these rules when working with clipboard callbacks and data:
- User data reaches all three callbacks. The
ULClipboardstruct passes itsuser_datapointer toclear,read, andwrite. - Build a payload to return from read.
ulCreateClipboardDataFromText()creates a payload containing a singletext/plainentry. To support additional formats, callulCreateClipboardData()and add entries withulClipboardDataSetText()orulClipboardDataSetBytes(). - The write payload is borrowed. The payload expires when the
writecallback returns. CallulCreateClipboardDataRef()to retain the payload past the call (such as for delayed rendering), and release it withulDestroyClipboardData()when finished. - Getter functions return owned strings. Calls like
ulClipboardDataGetTypeAt()andulClipboardDataGetText()return newULStringhandles that you must destroy withulDestroyString().ulClipboardDataGetText()returnsNULLwhen an entry contains binary bytes instead of text. - Callbacks run on the Renderer's thread. The renderer invokes clipboard callbacks only on that thread.
For atomic clipboard writing rules and standard format types, see Clipboard Integration.
Other Platform Handlers
Audio Output
ULAudioOutput and ulPlatformSetAudioOutput() require the Pro edition or higher, so wrap any code using them in an #if UL_HAS(MEDIA) guard.
#if UL_HAS(MEDIA)
void InstallAudioOutput(void* mixer) {
ULAudioOutput audio_output = {
.user_data = mixer,
.create_stream = OnCreateStream,
.push_samples = OnPushSamples,
.set_volume = OnSetVolume,
.set_paused = OnSetPaused,
.flush = OnFlush,
.stop = OnStop,
.destroy_stream = OnDestroyStream,
// get_playback_position stays NULL: video follows the system clock.
};
ulPlatformSetAudioOutput(audio_output);
}
#endif
Leaving get_playback_position set to NULL causes the playback position to report 0— video then follows the system clock instead of the audio clock.
For audio stream lifecycle rules, threading considerations, and buffer backpressure, see Media and Audio.
Creating Threads
In ULThreadFactory, the create_thread callback receives the thread name as a plain const char* (which can be NULL). ULThreadHandle is an unsigned long long— on POSIX, cast a pthread_t through uintptr_t to store it. For which threads the renderer creates and what to report, see Custom Threads.