Tutorial 117: The CNA::Devices Layer: Building With It, and Without It
What you’ll learn
- That
CNA::Devicesis compiled out of a stock build, and exactly what that leaves behind. - How the
CNA_DEVICESdefine reaches your code, and how to write one codebase that builds with and without it. - Which platform service each of the ten classes goes through, and how far it reaches on each of CNA’s platform implementations.
- The layer’s failure contract — capabilities degrade to a documented value, with one constructor that throws — and how to test it without opening a real OS dialog.
Before you start — Tutorial 88: Native OS Integration with CNA::Devices is the class-by-class walkthrough of this layer, with a worked example for each entry point. This tutorial is about the part that surrounds it: getting the layer into your build at all, and shipping a game that still works when it is not there.
XNA 4.0 had no file picker, no clipboard, no battery query and no webcam. There was nothing to reimplement, so CNA::Devices is not a reimplementation — it is a set of ten small classes over CNA’s platform contract that a real game needs and the XNA surface never offered. Every class header says so in the same words: “CNA extension — no XNA/WP7 equivalent exists.” The windowing platform is SDL3, which reaches Windows, X11, Wayland, macOS, Android and the browser through SDL’s own drivers; CNA also has two platforms that open no window (HEADLESS, TERMINAL). The layer reaches only as far as the platform you selected.
Which also means it is not part of the contract CNA promises to honour, and it is not in your build unless you ask.
It is compiled out by default
option(CNA_DEVICES "..." OFF). A stock build ships an empty cna_devices_ext library and none of its tests. This is not a stub layer that returns false at runtime — there is no code there at all.
The mechanism is worth seeing, because it explains why the failure mode is a compile error rather than a link error or a runtime surprise. Every public header and every implementation file in the module is wrapped in #ifdef CNA_DEVICES. With the option off, the module's translation units still compile — they just compile to nothing, and the module contributes no symbols. The ten Devices-EXT test files are gated the same way, so their 56 statically discoverable GoogleTest-family definitions are not compiled either.
Turning it on is one flag:
cmake -S . -B build -DCNA_DEVICES=ON
cmake --build build -j$(nproc)
Three CMake presets already set it, if you want a sanitizer build of this layer specifically:
cmake --preset devices-asan # AddressSanitizer, OPENGLES3
cmake --preset devices-ubsan # UndefinedBehaviorSanitizer
cmake --preset devices-tsan # ThreadSanitizer
How the flag reaches your code
CNA propagates CNA_DEVICES as a public compile definition on a shared interface target that every module — and transitively every consumer — links against. So the macro your game tests is literally the same macro the headers test. There is no second switch to keep in sync, and no way for your code to believe the layer is present while the headers believe it is absent.
That makes the portable pattern straightforward. Put the whole dependency behind one thin wrapper of your own, and your game compiles in both configurations:
// Platform/NativeIntegration.hpp -- the only file that knows about CNA::Devices
#pragma once
#include <string>
namespace MyGame::Native
{
bool CopyToClipboard(const std::string& text);
int BatteryPercentOrMinusOne();
}
// Platform/NativeIntegration.cpp
#include "Platform/NativeIntegration.hpp"
#ifdef CNA_DEVICES
#include "CNA/Devices/Clipboard.hpp"
#include "CNA/Devices/PowerInfo.hpp"
#endif
namespace MyGame::Native
{
bool CopyToClipboard(const std::string& text)
{
#ifdef CNA_DEVICES
return CNA::Devices::Clipboard::setTextProperty(text);
#else
(void)text;
return false; // same answer the layer gives when there is no clipboard
#endif
}
int BatteryPercentOrMinusOne()
{
#ifdef CNA_DEVICES
return CNA::Devices::PowerInfo::getBatteryPercentProperty();
#else
return -1; // same sentinel the layer uses for "unknown"
#endif
}
}
Note what the fallbacks return. They are not invented: they are the exact values the real implementations produce when the capability is unavailable. That is deliberate, and it is the layer's most useful property.
The ten classes and the platform services they go through
Every class here is a thin wrapper over one service of the platform contract (CNA::Platform::IPlatform): dialogs, clipboard, system info and power, displays, tray, camera. The module links no native library and calls no SDL function itself; the platform you select with CNA_PLATFORM supplies each service or does not. That is what “how far it reaches” means: it is a property of the platform, not of the class.
| Class | Shape | Platform service | How to ask |
|---|---|---|---|
MessageBox | static, blocking | dialogs | getIsSupportedProperty() answers the platform’s messageBox capability |
FileDialog | static, asynchronous | dialogs | getIsSupportedProperty(): false on Web and iOS targets, otherwise the platform’s nativeFileDialog capability |
Clipboard | static | clipboard | no query: an absent service gives false / an empty string |
UrlLauncher | static | system info (OpenUrl) | no query: Open() returns false |
PowerInfo | static | system info (GetPowerInfo) | no query: Unknown state and -1 readings |
SystemInfo | static | system info | no query; see the value table below |
Locale | static | system info (GetPreferredLocales) | no query: an empty list |
DisplayInfo | static, takes a GameWindow | displays, plus the window’s own scale | no query; 0.0f / Rectangle::Empty |
SystemTray | instance, non-copyable | tray | getIsSupportedProperty(); the constructor throws if it is false |
Camera | instance, non-copyable | camera provider | getIsSupportedProperty() and, per device, getStateProperty() |
What each platform implementation provides, read from the capability flags each platform sets in modules/platform/src:
| Platform | Message box | File dialog | Clipboard | Power | Tray | Camera |
|---|---|---|---|---|---|---|
SDL3 (default) | yes | yes | yes | yes | yes on Windows, Linux and macOS builds; no elsewhere | yes when SDL has a camera driver and can start it |
HEADLESS, TERMINAL | no | no | no | no | no | no |
“No” means the capability flag is false and the service accessor returns null, so the class degrades to its documented value. On HEADLESS and TERMINAL the system-info classes answer from a generic implementation: the core count from std::thread::hardware_concurrency(), 0 MB of RAM, no locales, an Unknown power state, and UrlLauncher::Open() returns false. Read this as a description of CNA’s code, not a promise about any device: we did not run the layer on Android, iOS or the browser. iOS support is experimental (a toolchain file, device final-links and a one-frame simulator smoke app in CI), and tvOS is unsupported.
Two things people get wrong. FileDialog has a real Android backend through the SDL3 platform, so it is not desktop-only, contrary to the layer’s early design notes; and SystemTray is genuinely limited to Windows, Linux and macOS builds of the SDL3 platform and nothing else — no runtime check will change that. Note also that the header comment on MessageBox::getIsSupportedProperty() still says “always true”; the code answers from the platform.
Failure is a value, with one exception
Almost nothing in this layer throws to tell you a capability is missing. Every entry point has a documented “could not determine” result, and your code is expected to branch on it. This is what lets the same call site work on a laptop, a phone and a headless test runner:
| Call | Unavailable result |
|---|---|
PowerInfo::getStateProperty() | PowerState::Unknown where the platform cannot tell (PowerState::Error if its query failed; the enum also has OnBattery, NoBattery, Charging, Charged) |
PowerInfo::getBatteryPercentProperty() / getSecondsRemainingProperty() | -1 — and some platforms can report only one of the two, never both |
Clipboard::setTextProperty(), UrlLauncher::Open() | false |
Clipboard::getTextProperty() | empty string |
DisplayInfo::getContentScaleProperty() | 0.0f without a platform window; otherwise the larger of the window’s display scale and its display’s content scale |
DisplayInfo::getSafeAreaProperty() | Rectangle::Empty |
Locale::getPreferredLocalesProperty() | empty vector |
MessageBox::Show() / ShowSimple() | -1 if the dialog could not be shown / does nothing |
FileDialog callback | empty file list when the dialog is unsupported — cancel and error look identical. If the platform throws while starting the dialog, the exception is swallowed and the callback is never called. |
Camera::getStateProperty() | CameraState::NotSupported when no camera could be opened; frame size reads 0 |
SystemInfo::getLogicalCpuCoreCountProperty() | documented as at least 1, and SDL3 answers that; a non-SDL platform answers std::thread::hardware_concurrency(), which the standard allows to be 0, so clamp it yourself: std::max(1, cores) |
SystemInfo::getSystemRamMegabytesProperty() | 0 where the platform cannot tell |
The exception: SystemTray’s constructor throws. It throws CNA::Platform::PlatformNotSupportedException when the platform has no tray service, and CNA::Platform::PlatformException if the service returns no icon. Guard it with SystemTray::getIsSupportedProperty(), and consider a try/catch as well, because a platform can report support on paper and still have nothing to attach to:
std::unique_ptr<CNA::Devices::SystemTray> tray;
if (CNA::Devices::SystemTray::getIsSupportedProperty()) {
try {
tray = std::make_unique<CNA::Devices::SystemTray>("My Game -- running");
} catch (const CNA::Platform::PlatformException&) {
// supported on paper, unavailable now: carry on without a tray icon
}
}
Two of the value rows deserve a second look. UrlLauncher::Open() returning true means only that something was launched, not that the page loaded — and on mobile it may push your game to the background. And the FileDialog ambiguity is real: SDL3 itself distinguishes cancel from error, but this wrapper does not currently surface the difference, so never report “save failed” on an empty list.
Camera: a poll loop, not a callback
Camera is the one class with a lifecycle worth writing out in full. It is deliberately poll-based, because SDL_AcquireCameraFrame() is — forcing an event model on top would have been an invention, not a wrapper. Call it once per frame alongside your other updates.
The state machine matters more than the API. On most platforms the OS asks the user for permission, and that answer can arrive seconds or minutes after you opened the device, so getStateProperty() re-checks on every call:
#include "CNA/Devices/Camera.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
using namespace CNA::Devices;
using Microsoft::Xna::Framework::Graphics::Texture2D;
// --- LoadContent ---
if (Camera::getIsSupportedProperty())
{
for (const CameraDeviceInfo& info : Camera::getAvailableCamerasProperty())
{
// info.Name, info.Position: Unknown / FrontFacing / BackFacing.
// Enumeration alone never asks for permission or opens a device.
}
camera_ = std::make_unique<Camera>(); // opens the first available device
}
// --- Update, every frame ---
if (!camera_) return;
switch (camera_->getStateProperty())
{
case CameraState::Opening: // permission dialog is up; keep waiting
case CameraState::Closed:
return;
case CameraState::Denied: // user said no -- stop asking
case CameraState::NotSupported:
case CameraState::Lost:
camera_.reset();
return;
case CameraState::Ready:
break;
}
// The texture must already match the camera's frame size exactly,
// or TryAcquireFrame returns false without touching it.
const int w = camera_->getFrameWidthProperty();
const int h = camera_->getFrameHeightProperty();
if (!frame_ || frame_->getWidthProperty() != w || frame_->getHeightProperty() != h)
frame_ = std::make_unique<Texture2D>(device, w, h);
if (camera_->TryAcquireFrame(*frame_))
{
// A new frame arrived. Draw frame_ with SpriteBatch like any other texture.
}
// false simply means "no new frame yet" -- not an error. Do not block on it.
First-implementation scope. One camera device (the first the platform reports — there is no device selection yet), synchronous permission polling with no SDL event-queue integration, and RGBA8-only frame delivery. CameraState::Closed is defined but the current implementation never returns it (a camera that could not be opened reports NotSupported); the SDL3 platform reports Lost for a device whose negotiated format is not RGBA8. Only the SDL3 platform provides a camera at all.
Testing, and the two classes you must never let loose
Two classes are non-negotiable in an automated test. A real file dialog on Linux launches a zenity process that waits forever for a human — this actually happened during the layer’s own development and left orphaned processes on a real desktop — and a real message box blocks the calling thread until someone clicks. Earlier snapshots protected against this with a SetBackendForTesting() hook on the public classes; that hook no longer exists. The seam is now the platform contract: the layer asks CNA::Platform::GetCurrentPlatform() for its services, so a test installs a platform whose dialog service records instead of showing.
CNA’s own tests use two small header-only helpers, CNA::Platform::Testing::CannedDialogPlatform (a platform whose dialogs record their calls and deliver scripted results) and ScopedCurrentPlatform (installs a platform for a scope and restores the previous one, even if an assertion returns early). They live in the CNA repository’s test tree, modules/platform/tests/CNA/Platform/, and are not an installed public header: add that directory to your test target’s include path, or copy the idea. The public pieces they rest on are CNA::Platform::SetCurrentPlatform(IPlatform*), ResetCurrentPlatform() and the IPlatformDialogs interface.
#include "CNA/Devices/FileDialog.hpp"
#include "CNA/Devices/MessageBox.hpp"
#include "CNA/Platform/CannedDialogs.hpp" // from modules/platform/tests
using CNA::Platform::Testing::CannedDialogPlatform;
using CNA::Platform::Testing::ScopedCurrentPlatform;
void MessageBoxNeverShowsInATest()
{
CannedDialogPlatform platform; // records instead of showing
ScopedCurrentPlatform installed(platform); // restored when this scope ends
platform.Canned().chosenButton = 1; // the "user" clicks the second button
const int clicked = CNA::Devices::MessageBox::Show(
CNA::Devices::MessageBoxType::Warning, "Quit", "You have unsaved progress.",
{"Save and quit", "Quit anyway"});
assert(clicked == 1);
assert(platform.Canned().messageBoxes.size() == 1);
assert(platform.Canned().messageBoxes.front().title == "Quit");
}
void FileDialogNeverShowsInATest()
{
CannedDialogPlatform platform;
ScopedCurrentPlatform installed(platform);
platform.Canned().chosenPaths = {"/tmp/slot1.sav"}; // what every dialog's callback receives
std::vector<std::string> received;
CNA::Devices::FileDialog::ShowOpenFile(
[&](const std::vector<std::string>& files) { received = files; },
{{"Saved game", "sav"}}); // the canned platform calls back at once
assert(received.size() == 1 && received.front() == "/tmp/slot1.sav");
assert(platform.Canned().fileDialogs.front().kind == "open");
}
The same approach covers the rest of the layer: SystemTray and Camera take no injected backend either — they use the current platform’s tray and camera services, and CNA has canned platforms for those too (CannedTray.hpp, CannedCamera.hpp). SetBackendForTesting() still exists in CNA, but only on the Microsoft::Devices::Sensors classes.
CNA’s own coverage follows exactly this rule: 56 statically discoverable GoogleTest-family definitions across 10 test files under the Devices extension module, driving canned platforms rather than real native dialogs or trays — which is what makes them safe on a headless runner. This is a source count, not a pass claim; run the configured build:
cmake -S . -B build -DCNA_DEVICES=ON -DCNA_BUILD_TESTS=ON
cmake --build build --target CnaTests -j$(nproc)
./build/CnaTests --gtest_filter='ClipboardTests.*:PowerInfoTests.*:CameraTests.*'
CI coverage. The Devices and Sensors Tests workflow (devices-tests.yml) builds the devices-ubsan preset (CNA_DEVICES=ON) and runs the suites named in its two --gtest_filter lists under Xvfb. Those lists miss seven Microsoft::Devices suites and, because two of the ten CNA::Devices names (FileDialogTests, MessageBoxTests) match no suite after a rename, four of the twelve CNA::Devices suites (the FileDialog and MessageBox cases) never run there (CNA-BUG-035). It triggers on pushes and pull requests to next, develop and main that touch the module paths (and on manual dispatch); earlier snapshots had it watching only master and main. A workflow existing is not a green run — we did not see one.
One thing this is not
Do not confuse CNA::Devices with Microsoft::Devices. The latter is the Windows Phone 7 sensor layer — Accelerometer, Gyroscope, Compass, Motion, VibrateController — and it is a genuine reimplementation of a real API that really existed. It lives in a separate module (modules/devices) that is always compiled and is not behind CNA_DEVICES; the two namespaces share a directory-name prefix, a CI workflow and a strict-API harness, and nothing else. Sensors are covered by Tutorial 50 and the Sensors reference.
Where to go next
- Tutorial 88: Native OS Integration with CNA::Devices — the class-by-class walkthrough
- Tutorial 115: CNAEXT — extending beyond XNA
- Tutorial 124: WebAssembly gotchas — what else changes in the browser
- Tutorial 99: Unit testing CNA game logic
- Sensors reference and Platforms reference
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Host devices: the optional CNA::Devices layer — The CNA_DEVICES build contract, what each CNA::Devices class really does, its platform reach, the overlap with CNA::Input, the camera polling model and the callback boundary callers must respect.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-035: devices-tests.yml's exact-name gtest filters skip eight Microsoft::Devices suites and never match the FileDialog and MessageBox suites — DEVICES_GTEST_FILTER misses 33 of 478 TEST definitions under modules/devices/tests, and CNA_DEVICES_GTEST_FILTER names FileDialogTests.* and MessageBoxTests.*, which match no suite, so the 12 FileDialog and MessageBox ca