Tutorial 88: Native OS Integration with CNA::Devices
What you’ll learn
- What lives in the
CNA::Devicesnamespace and why none of it is XNA. - Showing native message boxes (synchronous) and file dialogs (asynchronous, callback-delivered).
- Reading the clipboard, launching URLs, and querying battery, CPU, RAM, locale and safe-area.
- Injecting fake backends so automated tests never open a real OS dialog.
Before you start — Tutorial 19: Saving and Loading Data (this layer is what puts a real "Save As…" dialog in front of it) and Tutorial 20: Building and Running Your Game (you must reconfigure CMake). Requires -DCNA_DEVICES=ON; the option defaults to OFF, so on a stock build none of these headers compile.
What this layer is
Everything under include/CNA/Devices/ is a CNA extension. Each header states it in the same words: "CNA extension — no XNA/WP7 equivalent exists." XNA 4.0 had no native file picker, no clipboard, no battery query, so there was nothing to reimplement — these classes exist because a real game needs them and the XNA surface never offered them.
Do not confuse this with Microsoft::Devices, the Windows Phone 7 sensor layer (accelerometer, compass), which is a genuine reimplementation of a real WP7 API and is covered separately. The two share a build flag but nothing else.
The build flag defaults to OFF. CMakeLists.txt declares option(CNA_DEVICES "Enable CNA-specific device/sensor extensions beyond XNA 4.0 (battery, camera, clipboard, ...)" OFF). Every header in this tutorial is wrapped in #ifdef CNA_DEVICES, so without the flag they expand to nothing and your CNA::Devices::... references fail to resolve. Configure with -DCNA_DEVICES=ON.
The namespace at a glance
| Class | Shape | What it does |
|---|---|---|
MessageBox | static, synchronous | Native modal alert with your own buttons. |
FileDialog | static, asynchronous | Open-file, save-file, open-folder pickers. |
Clipboard | static | Read/write UTF-8 clipboard text. |
UrlLauncher | static | Open a URL in the system's default handler. |
PowerInfo | static | Battery state, percentage, seconds remaining. |
SystemInfo | static | Logical CPU core count, system RAM in MB. |
DisplayInfo | static, takes a GameWindow | Content scale factor and safe area rectangle. |
Locale | static | The user's preferred locales, in order. |
SystemTray | instance, non-copyable | Tray icon with a menu of clickable entries. |
Camera | instance, non-copyable | Acquire webcam frames into a Texture2D. |
Message boxes are synchronous
MessageBox blocks the calling thread until the user answers. getIsSupportedProperty() is unconditionally true — a real native backend exists on every platform CNA targets, including Web/Emscripten.
#include "CNA/Devices/MessageBox.hpp"
#include "CNA/Devices/MessageBoxType.hpp"
using namespace CNA::Devices;
// Single-button alert, platform's own default wording.
MessageBox::ShowSimple(MessageBoxType::Error,
"Load failed",
"The save file is corrupt and was not loaded.");
// Custom buttons; returns the index the user clicked, or -1 if the
// dialog could not be shown at all.
const int choice = MessageBox::Show(
MessageBoxType::Warning,
"Quit",
"You have unsaved progress.",
{ "Save and quit", "Quit anyway", "Cancel" });
if (choice == 0) { SaveGame(); Exit(); }
else if (choice == 1) { Exit(); }
// choice == 2 or -1: stay in the game
MessageBoxType has exactly three values: Error, Warning, Information.
File dialogs are asynchronous
This is the single most important difference in the layer. FileDialog calls return immediately. The result arrives later through onResult, and the header warns it may arrive on a different thread than the one that opened the dialog. Never write code that assumes the file list is ready on the next line.
#include "CNA/Devices/FileDialog.hpp"
#include "CNA/Devices/FileDialogFilter.hpp"
#include <vector>
#include <string>
using namespace CNA::Devices;
if (!FileDialog::getIsSupportedProperty()) {
// No native dialog backend on this platform (iOS, Web/Emscripten).
// Fall back to a fixed save slot.
return;
}
const std::vector<FileDialogFilter> filters = {
{ "Saved game", "sav" },
{ "All files", "*" }
};
FileDialog::ShowSaveFile(
[](const std::vector<std::string>& files) {
// May run on another thread — marshal to the game thread
// rather than touching graphics state here.
if (files.empty()) {
return; // user canceled, or an error occurred
}
QueueSaveTo(files[0]);
},
filters,
""); // defaultLocation: empty means platform-default
Cancel and error look identical. The header is explicit: the callback receives an empty file list both when the user canceled and when the dialog failed. SDL3 itself distinguishes the two cases; this wrapper does not currently surface that distinction, so do not report "save failed" on an empty list.
The other two entry points mirror the same shape:
ShowOpenFile(onResult, filters, defaultLocation, allowMultiple)ShowOpenFolder(onResult, defaultLocation, allowMultiple)— no filters, folders have no extensions
A FileDialogFilter is two strings: Name (the user-readable label) and Pattern (semicolon-separated extensions such as "doc;docx", or a single "*"). Patterns may only contain alphanumerics, hyphens, underscores and periods.
Platform reach differs between the two dialog classes, and it is worth knowing before you design a UI around either:
| Capability | Desktop | Android | iOS | Web/Emscripten |
|---|---|---|---|---|
MessageBox | yes | yes | yes | yes |
FileDialog | yes | yes | no | no |
Dialogs shown by FileDialog are currently non-modal — the layer always passes nullptr for SDL3's parent-window parameter, so a dialog is never tied to a specific game window.
Clipboard and URL launcher
Both are small enough to show in full. Note the property-style naming CNA uses throughout this namespace: setTextProperty / getTextProperty rather than plain setters.
#include "CNA/Devices/Clipboard.hpp"
#include "CNA/Devices/UrlLauncher.hpp"
using namespace CNA::Devices;
// Copy a multiplayer invite code out of the game.
Clipboard::setTextProperty("CNA-4821-XJ"); // returns bool
// Paste it back in.
if (Clipboard::getHasTextProperty()) {
const std::string code = Clipboard::getTextProperty();
TryJoinLobby(code);
}
// "Open homepage" menu item.
UrlLauncher::Open("https://libcna.com"); // returns bool
Clipboard members are main-thread-only. On Web/Emscripten browser clipboard access is permission-gated and generally needs an active user-gesture context; outside one, calls fail gracefully — returning false or an empty string — rather than crashing. UrlLauncher::Open returning true means only that something was launched, not that the page loaded; on mobile it may push your game to the background.
Querying the machine
#include "CNA/Devices/PowerInfo.hpp"
#include "CNA/Devices/PowerState.hpp"
#include "CNA/Devices/SystemInfo.hpp"
#include "CNA/Devices/Locale.hpp"
using namespace CNA::Devices;
// Drop to a 30 Hz cap when running unplugged and low.
const PowerState power = PowerInfo::getStateProperty();
const int pct = PowerInfo::getBatteryPercentProperty(); // 0..100, or -1
if (power == PowerState::OnBattery && pct >= 0 && pct < 20) {
EnableBatterySaverPreset();
}
// Size a worker pool and pick a quality preset.
const int cores = SystemInfo::getLogicalCpuCoreCountProperty(); // always >= 1
const int ramMB = SystemInfo::getSystemRamMegabytesProperty();
// First preferred locale wins.
for (const LocaleInfo& loc : Locale::getPreferredLocalesProperty()) {
if (HaveTranslationFor(loc.Language)) { // e.g. "en"
SelectLanguage(loc.Language, loc.Country); // Country may be empty
break;
}
}
PowerState has six values: Error, Unknown, OnBattery, NoBattery, Charging, Charged. Both getBatteryPercentProperty() and getSecondsRemainingProperty() return -1 when the value cannot be determined or there is no battery — and the header cautions that some platforms can report only a percentage or only a time estimate, never both. Treat battery readings as a hint, not truth.
DisplayInfo is the one class here that needs a window:
#include "CNA/Devices/DisplayInfo.hpp"
using Microsoft::Xna::Framework::Rectangle;
const float scale = CNA::Devices::DisplayInfo::getContentScaleProperty(Window);
const Rectangle safe = CNA::Devices::DisplayInfo::getSafeAreaProperty(Window);
// Lay HUD elements inside `safe` so a notch or rounded corner never clips them.
System tray
SystemTray is an instance class, non-copyable, and owns its backend. AddEntry returns the index you use for every later mutation.
#include "CNA/Devices/SystemTray.hpp"
using namespace CNA::Devices;
if (!SystemTray::getIsSupportedProperty()) {
return;
}
SystemTray tray("My Game — running");
const std::size_t muteIdx = tray.AddEntry(
"Mute audio",
/*checkable*/ true,
/*initiallyChecked*/ false,
/*initiallyEnabled*/ true,
[]() { ToggleMute(); });
tray.AddEntry("Quit", false, false, true, []() { RequestExit(); });
// Later:
tray.setTooltipProperty("My Game — paused");
tray.SetEntryChecked(muteIdx, true);
const bool muted = tray.GetEntryChecked(muteIdx);
tray.SetEntryEnabled(muteIdx, false);
tray.SetEntryLabel(muteIdx, "Mute audio (locked)");
Camera
Camera hands you frames as a Texture2D, so a webcam feed can go straight into a SpriteBatch draw. TryAcquireFrame returns false when no new frame is ready — poll it, do not block on it.
#include "CNA/Devices/Camera.hpp"
using namespace CNA::Devices;
using Microsoft::Xna::Framework::Graphics::Texture2D;
for (const CameraDeviceInfo& info : Camera::getAvailableCamerasProperty()) {
// info.Name, info.Position (Unknown / FrontFacing / BackFacing)
}
Camera camera;
if (camera.getStateProperty() == CameraState::Ready) {
if (camera.TryAcquireFrame(frameTexture)) {
const int w = camera.getFrameWidthProperty();
const int h = camera.getFrameHeightProperty();
// frameTexture now holds the latest frame
}
}
CameraState models the full permission lifecycle: NotSupported, Closed, Opening, Denied, Ready, Lost. Opening and Denied exist because on most platforms the OS asks the user for camera permission — a camera that is not Ready yet is not necessarily broken.
Testing without opening real dialogs
FileDialog and MessageBox both route through a Detail::I*Backend interface rather than calling SDL3 directly, and both expose SetBackendForTesting. This is not incidental — the headers spell out why these two matter more than the rest of the layer: a real file dialog can leave a zenity process open forever waiting for a human, and a real message box blocks the calling thread until someone clicks. An automated test must never reach the real backend.
#include "CNA/Devices/FileDialog.hpp"
#include "CNA/Devices/Detail/IFileDialogBackend.hpp"
class FakeFileDialogBackend final : public CNA::Devices::Detail::IFileDialogBackend {
public:
void ShowOpenFile(CNA::Devices::Detail::FileDialogResultCallback onResult,
const std::vector<CNA::Devices::FileDialogFilter>&,
const std::string&,
bool) override {
onResult({ "/fixtures/slot1.sav" }); // answer instantly
}
void ShowSaveFile(CNA::Devices::Detail::FileDialogResultCallback onResult,
const std::vector<CNA::Devices::FileDialogFilter>&,
const std::string&) override {
onResult({ "/tmp/out.sav" });
}
void ShowOpenFolder(CNA::Devices::Detail::FileDialogResultCallback onResult,
const std::string&,
bool) override {
onResult({}); // simulate cancel
}
};
// In test setup:
CNA::Devices::FileDialog::SetBackendForTesting(
std::make_unique<FakeFileDialogBackend>());
// In test teardown — nullptr restores the real platform backend:
CNA::Devices::FileDialog::SetBackendForTesting(nullptr);
The same pattern applies to MessageBox::SetBackendForTesting. SystemTray and Camera take their backend through a constructor overload instead, since they are instance classes.
The neighbouring extension surfaces
Two other CNA-only namespaces are easy to confuse with this one, and neither is gated behind CNA_DEVICES:
CNA::Input(Joysticks,Haptics,InputDevices,Sensors,Power) — always compiled. Its members carry theNOXNAmacro andEXT-suffixed names such asJoysticks::GetJoysticksEXT().NOXNAexpands to[[deprecated("NOXNA: not part of the XNA 4.0 API surface")]]under the strict-purity build, so a project that wants to stay XNA-pure gets a compiler warning the moment it touches one. Under a normal build it expands to nothing.CNA::Logger— also always compiled.Logger::Info(msg, LogCategory::RENDER)and friends, plus conditional variants (InfoIf,WarnIf, …). Levels runFATAL,ERROR,WARN,INFO,DEBUG,TRACE, andEXPERIMENT; categories areAPPLICATION,ERROR,SYSTEM,AUDIO,VIDEO,RENDER,INPUT,TEST,GPU.
Note that CNA::Input::Power and CNA::Devices::PowerInfo both report battery status through different shapes — Power::GetInfoEXT(secondsLeft, percent) uses out-parameters, PowerInfo uses three separate property getters. Pick one and stay consistent; the CNA::Devices form does not require the NOXNA escape but does require the build flag.