In this article, we touch on various options for authoring cross-platform graphical user interfaces (GUIs) in C++ and discuss why CEF might be a suitable choice for your application. Then, we build a small demonstration application using CEF targeting WoA. Finally, we show the workflows you need to deploy and debug the application.
Options for Authoring WoA GUI Applications
Before going into CEF in more detail, let’s first discuss the benefits and challenges of other options for authoring WoA GUI applications.
Microsoft Frameworks
If you already familiar with one of Microsoft’s existing frameworks and APIs for building GUI applications, you can likely use your framework of choice. Native WoA applications can be built using:
- Win32 API
- UWP
- Windows Forms - Supported as of .NET 5.0
- WPF - Supported as of .NET 5.0.8
- Xamarin Forms
Using one of these frameworks may let you re-use existing knowledge and code. However, with the exception of Xamarin Forms, none of them are cross-platform.
Electron
Another application-building option is the popular Electron framework. Electron is a framework for building desktop applications with JavaScript, HTML, and CSS. Electron embeds Chromium and Node.js into its binary. This allows you to maintain one Javascript codebase and create cross-platform apps that can interact directly with the operating system (OS). In a typical Electron app, you author the user interface (UI) and frontend logic using web technologies (HTML and CSS) in conjunction with JavaScript (JS) or TypeScript. You can call native code from JS using node-addon-api.
Electron applications have at least a main process and one or more renderer processes. Any process can link shared libraries containing native code. To invoke native functions, you must marshal data to and over the application binary interface (ABI) boundary, which can incur some overhead.
Electron’s chief benefit is that web UIs are extremely portable. Furthermore, the Electron framework provides a portable API to interface with various OS facilities, such as the taskbar and clipboard across multiple platforms including iOS, MacOS, Android, Windows, and WoA.
Electron is a great choice when the majority of the business logic does not need native performance. It also offers the ability to use native extensions for parts of your application where performance is critical. When packaging an Electron app, you must take care to ensure the native code is compiled for the current target architecture.
The main drawback of Electron is size: a minimal application weighs in at 30 megabytes. Applications are also pinned to the version of Chromium embedded in the Electron build they use. This can be beneficial because it ensures your application won’t break due to changes in Chromium. It’s also a risk due to occasional security vulnerabilities in Chromium. When zero day vulnerabilities appear, you must wait until the issue is patched in Chromium and a new Electron build incorporates the Chromium update before you can update your application to keep it secure.
Chromium Embedded Framework
Another app creation option is the Chromium Embedded Framework (CEF), which gained official support for Windows on Arm in early 2021. Like Electron, CEF builds on the Chromium project to provide cross-platform GUI functions.
Whereas Electron generally functions as the "host" application, you can embed CEF into an existing native application. Instead of a JavaScript API, CEF’s C and C++ APIs manage its runtime.
CEF may be a bit less approachable as a framework than Electron. It also has a smaller ecosystem. However, CEF is a good choice when a significant portion of the application needs to be, or is already, native code. It’s also smaller than Electron, at 4MB instead of 30MB. Like Electron, CEF introduces a bit of risk due to the potential of zero day vulnerabilities in Chromium. When these appear, you’ll have to wait for a build of CEF incorporating the fixes and then create a new build of your application.
Starting with CEF on WoA
While Visual Studio can run on a WoA device, it is not yet officially supported. As such, we will build and compile our sample project on a host x86_64 machine. We will use Visual Studio remote tools to deploy and test our application on an Arm-powered Surface Pro X running Windows 10. For this demo, we use C++17.
Before you start, ensure that you have installed Visual Studio’s ARM64 build tools. To do this, open the Visual Studio Installer, click on the Modify button for VS2019, choose Desktop development with C++, check the MSVC v142 – VS 2019 C++ ARM64 build tools checkbox, and then click the Modify button at the bottom right of the window:
When you need to debug the application on the target Arm device, you can use ARM Debugging Tools for Windows or remotely debug your app from the host x86 machine using Visual Studio remote debugging tools.
It is easiest to integrate CEF as part of a CMake project, so start by creating an empty directory for a CMake.
Also, download and extract CEF’s "Minimal Distribution" binary release. Rename the extracted folder to "cef_arm64" for fast identification and place it just under your project’s root.
We will also want to test the app on the host x86_64 machine, so download the Windows 64-bit minimal distribution binary. Extract it to the same folder and rename the extracted contents "cef_win64":
Next, let us set up our root CMake project file. Paste the following in an empty CMakeLists.txt file:
cmake_minimum_required(VERSION 3.19)
project(woa_cef LANGUAGES C CXX)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(${CMAKE_GENERATOR_PLATFORM} MATCHES "arm64")
set(CEF_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/cef_arm64 CACHE INTERNAL "")
else()
set(CEF_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/cef_win64 CACHE INTERNAL "")
endif()
add_subdirectory(${CEF_ROOT})
add_executable(
woa_cef
# NOTE: This property is needed to use WinMain as the entry point
WIN32
app.cpp
main.cpp
)
target_compile_features(
woa_cef
PRIVATE
cxx_std_17
)
target_link_directories(
woa_cef
PRIVATE
${CEF_ROOT}/Release
)
target_link_libraries(
woa_cef
PRIVATE
libcef
cef_sandbox
libcef_dll_wrapper
)
target_include_directories(
woa_cef
PRIVATE
${CEF_ROOT}
)
target_compile_definitions(
woa_cef
PRIVATE
_ITERATOR_DEBUG_LEVEL=0
)
set_property(
TARGET
woa_cef
PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>"
)
function(file_copy FILES BASE_DIR)
message(STATUS "FILES: ${FILES}")
foreach(FILE ${FILES})
get_filename_component(FILE_DIR ${FILE} DIRECTORY)
file(RELATIVE_PATH REL ${BASE_DIR} ${FILE_DIR})
get_filename_component(FILE_NAME ${FILE} NAME)
add_custom_target(
copy_${REL}_${FILE_NAME}
COMMAND ${CMAKE_COMMAND} -E echo ${CMAKE_CURRENT_BINARY_DIR}/$<IF:$<CONFIG:Debug>,Debug,Release>/${REL}
COMMAND ${CMAKE_COMMAND} -E copy ${FILE} ${CMAKE_CURRENT_BINARY_DIR}/$<IF:$<CONFIG:Debug>,Debug,Release>/${REL}/${FILE_NAME}
)
add_dependencies(woa_cef copy_${REL}_${FILE_NAME})
endforeach()
endfunction()
file(
GLOB_RECURSE
RESOURCE_FILES
"${CEF_ROOT}/Resources/*"
)
message(STATUS "CEF resources: ${RESOURCE_FILES}")
file_copy("${RESOURCE_FILES}" ${CEF_ROOT}/Resources)
file(
GLOB_RECURSE
DLLS
"${CEF_ROOT}/Release/*.dll"
)
message(STATUS "CEF dlls: ${DLLS}")
file_copy("${DLLS}" ${CEF_ROOT}/Release)
file(
GLOB_RECURSE
BINS
"${CEF_ROOT}/Release/*.bin"
)
message(STATUS "CEF bins: ${BINS}")
file_copy("${BINS}" ${CEF_ROOT}/Release)
The project file creates a new executable called "woa_cef
," including two C++ source files that we have yet to create: main.cpp and app.cpp.
To include and link CEF properly, we first set the CEF_ROOT
variable to the appropriate directory. We can use this variable to switch between target architectures such as ARM64, x86, and x86_64. Then, we will add the corresponding Release folder as a link directory.
Next, we will link the required libraries, libcef, cef_sandbox
, and libcef_dll_wrapper
. We will compile the DLL wrapper as part of the project, with the first two libraries being precompiled libraries in the release.
All the CEF headers expect that the root release path is in the header include path, so we will add that as an include directory. Our last compilation setting changes specifically for Microsoft Visual C++ (MSVC) are _ITERATOR_DEBUG_LEVEL
and MSVC_RUNTIME_LIBRARY
. This must match the CEF-linked settings.
Finally, we will use a small "file_copy
" function to ensure various runtime artifacts correctly copy to our binary directory as part of the build. We are copying several DLLs (needed for WebGL and other functions), locale data, V8 runtime snapshot binaries, and various packed resources.
With this, we can author some minimal code to get up and running. We will simply create an executable that spawns the Arm developer homepage in a CEF window. It terminates the program on close.
Then we write our application interface. In "app.hpp," insert the following contents:
#pragma once
#include <include/cef_app.h>
class App : public CefApp, public CefBrowserProcessHandler
{
public:
App() = default;
CefRefPtr<CefBrowserProcessHandler> GetBrowserProcessHandler() override
{
return this;
}
void OnContextInitialized() override;
private:
IMPLEMENT_REFCOUNTING(App);
};
Our App
class is a CefApp
, which is the top-level C++ wrapper around CEF’s C API. A typical CEF application has a browser process and one or more renderer processes, and CefApp
can handle callbacks for both processes. This reflects Chromium’s underlying process-per-site-instance model: a browser process that handle general tasks like window creation and network access, and a separate, sandboxed renderer process for each site you visit. The renderer process is responsible for rendering the site’s HTML/CSS and running JavaScript.
For convenience, our App
class also inherits from CefBrowserProcessHandler. CefBrowserProcessHandler
includes methods you can override if you need to do work at specific points during the creation of the browser process. If you need to do any complex work before or after the browser process initializes, you can also create a separate browser process handler class that inherits from CefBrowserProcessHandler
.
Here, we only handle browser process events inherited from CefBrowserProcessHandler
. In the body of our class declaration, we override the GetBrowserProcessHandler
method to advertise this class as a browser process handler. We also override the OnContextInitialized
browser event. In the private section of our App
class decoration, use the IMPLEMENT_REFCOUNTING
convenience macro to add boilerplate CEF needs on all classes that use ref-counting to manage lifetimes.
In the "app.cpp" file, we start fleshing out our application.
#include "app.hpp"
class Handler : public CefClient, public CefLifeSpanHandler
{
public:
Handler() = default;
CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override { return this; }
void OnBeforeClose(CefRefPtr<CefBrowser> browser) override
{
CefQuitMessageLoop();
}
private:
IMPLEMENT_REFCOUNTING(Handler);
};
static CefRefPtr<Handler> handler{new Handler};
void App::OnContextInitialized()
{
CefBrowserSettings browser_settings;
CefWindowInfo window_info;
window_info.SetAsPopup(nullptr, "CEF Demo");
CefBrowserHost::CreateBrowser(window_info, handler, "https://developer.arm.com/", browser_settings, nullptr, nullptr);
}
While the App
class handles events during the creation of the browser process, we must define an internal Handler
class to handle browser events fired by the browser process once it is running.
We handle only the single OnBeforeClose
event to shut down the CEF event loop in this minimal application. Otherwise, after the user closes the window, our application will still run in the background.
We only create a single window for our demo, so we will create a single static handler. You may use a different handler per window in a more complicated scenario and coordinate them to determine when the app should shut down.
We will spawn the window itself in the App
class’ OnContextInitialized
implementation. This event occurs on the browser process UI thread immediately after the CEF context initializes.
Leaving the default CefBrowserSettings
, we only change the Windows classification to be a Win32 pop-up window style with the title "CEF Demo." Then, we use CreateBrowser to spawn a window with our specified settings to load the Google homepage.
The final missing element in our demo is the "main.cpp" file, which needs to initialize and start the CEF context and event loop. The contents of the "main.cpp" source file are as follows:
#include "app.hpp"
#include <Windows.h>
int APIENTRY WinMain(HINSTANCE instance, HINSTANCE previous_instance, LPTSTR args, int command_show)
{
CefMainArgs main_args{instance};
int exit_code = CefExecuteProcess(main_args, nullptr, nullptr);
if (exit_code >= 0)
{
return exit_code;
}
CefSettings settings;
CefRefPtr<App> app(new App);
CefInitialize(main_args, settings, app.get(), nullptr);
CefRunMessageLoop();
CefShutdown();
return 0;
}
We use the typical WinMain entry point for GUI applications not expected to spawn a console. CefExecuteProcess
runs CEF, and CefInitialize
spawns the browser process. By registering our App
class as the browser’s CefApp, we receive the OnContextInitialized
event to generate the browser window as we implemented earlier. Finally, we run the message loop and tear everything down when the message loop finishes.
We will verify our compiled demo works using the ARM64 MSVC CMake generator by running:
<a>cmake -G "Visual Studio 16" -A arm64 -B build</a>
After the project is generated, open the build\woa_cef.sln in Visual Studio 2019 and select Build > Build Solution.
After compiling, the build/ARM64 directory folder should have the following content:
Since this build is cross-compiled for arm64 from an x86 machine, we’ll need to copy it to a WoA device to test it. Running the "woa_cef.exe" executable should spawn a window with the Arm developer homepage loaded:
To create an x64 build to try out on your development machine, run:
<a>cmake -G "Visual Studio 16" -A x64 -B build</a>
Open the solution file as described above, build it, and in the build/x64 directory you will find a woa_cef.exe file you can run.
Note that while browser navigation will work, the typical browser UI, such as menu bars and URL fields, is missing. Close this window with the X button in the top right to properly terminate our application.
This demo shows how, with relatively few lines of code, we were able to stand up a GUI application that cross-compiles trivially. We have done this from an x86 machine to an Arm64 machine using CEF. While this use case is simple, it is easy to see how you can extend the demo to handle diverse scenarios, such as multiple-windowed applications.
While in this case, we left most of the default settings, CEF makes it easy to customize most aspects of the runtime and browser process. In addition to changing static settings (as we did with the window title), we can intercept other browser events (on page load, navigation, download), window events (on window move, focus, minimize), and more.
To learn more about CEF’s general architecture and use, refer to the official General Usage wiki. The source code for a slightly more involved demo involving command-line argument parsing and multi-window scenarios is part of the main CEF repository.
Finally, refer to the Microsoft Windows 10 on ARM documentation portal for general information, guides, and references.
Windows on Arm enables a new generation of fast, lightweight computing solutions. Now that you know how, start building your own native Windows apps on Arm.