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. - What each of the ten classes actually sits on in SDL3, and how far it reaches per platform.
- The layer's failure contract — every capability degrades to a documented value, never an exception.
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 SDL3 that a real game needs and the XNA surface never offered. Every header says so in the same words: “CNA extension — no XNA/WP7 equivalent exists.”
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 resulting static library has no symbols. The ten test files are gated the same way, so the 50 GoogleTest case definitions they contain are never registered either.
Turning it on is one flag:
cmake -S . -B build -DCNA_DEVICES=ON
cmake --build build -j3
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 what they sit on
Everything here is a thin, honest wrapper over one SDL3 primitive. Knowing which one tells you what to expect on a platform you have not tested yet.
| Class | Shape | SDL3 primitive | Reach |
|---|---|---|---|
MessageBox | static, blocking | SDL_ShowMessageBox() | Every platform — getIsSupportedProperty() is unconditionally true |
Clipboard | static | SDL_GetClipboardText() family | Main-thread only; permission-gated in the browser |
UrlLauncher | static | SDL_OpenURL() | Desktop, Android, iOS, Web |
PowerInfo | static | SDL_GetPowerInfo() | Windows, Linux, macOS, Android, iOS, Web |
SystemInfo | static | SDL_GetNumLogicalCPUCores(), SDL_GetSystemRAM() | Every platform; Web approximates from navigator.hardwareConcurrency |
Locale | static | SDL_GetPreferredLocales() | Every platform; Web reads browser language settings |
DisplayInfo | static, takes a GameWindow | SDL_GetWindowDisplayScale(), SDL_GetWindowSafeArea() | Wherever the platform answers the query |
FileDialog | static, asynchronous | SDL_ShowOpenFileDialog() family | Desktop and Android; not iOS, not Web |
SystemTray | instance, non-copyable | SDL_CreateTray() family | Desktop only — no Android, iOS or Web renderer exists |
Camera | instance, non-copyable | SDL_OpenCamera(), SDL_AcquireCameraFrame() | Wherever SDL3 has a camera driver compiled in |
Two rows in that table are the ones people get wrong. FileDialog has a real Android backend — it is not desktop-only, contrary to what the layer's own early design notes assumed. SystemTray genuinely is desktop-only, and no amount of runtime checking will change that. Read the iOS column as descriptive of SDL3's code, not as a supported target: CNA does not support iOS or tvOS at any level.
Failure is a value, not an exception
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 the single most useful thing to internalise about the layer, because it is what lets the same call site work on a laptop, a phone and a browser tab:
| Call | Unavailable result |
|---|---|
PowerInfo::getStateProperty() | PowerState::Error (the enum also has Unknown, 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 |
DisplayInfo::getSafeAreaProperty() | Rectangle::Empty |
Locale::getPreferredLocalesProperty() | empty vector |
MessageBox::Show() | -1 if the dialog could not be shown |
FileDialog callback | empty file list — cancel and error look identical |
Camera::getStateProperty() | CameraState::NotSupported; frame size reads 0 |
SystemInfo::getLogicalCpuCoreCountProperty() | never fails — always at least 1 |
Two of these 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::Lost is defined but the current renderer never reaches it.
Testing, and the two classes you must never let loose
Four classes take an injectable renderer, and two of them are non-negotiable in an automated test. The reasons are concrete rather than stylistic: 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.
| Class | How the renderer is swapped | Why there |
|---|---|---|
FileDialog | SetBackendForTesting(), process-wide | Nothing runs until you call Show*, so a later swap is still in time |
MessageBox | SetBackendForTesting(), process-wide | Same — pass nullptr to restore the real renderer |
SystemTray | Constructor parameter | The real renderer creates the icon in the constructor; a later swap would be too late |
Camera | Constructor parameter | The real renderer opens the device immediately, for the same reason |
CNA's own coverage follows exactly this rule: 50 GoogleTest case definitions across 10 test files under the devices extension module, every one of them driving an injected fake rather than a real native dialog or tray — which is what makes them safe on a headless runner. As always, that is a count of what is defined, not a claim about what passes; run it yourself.
cmake -S . -B build -DCNA_DEVICES=ON -DCNA_BUILD_TESTS=ON
cmake --build build --target CnaTests -j3
./build/CnaTests --gtest_filter='ClipboardTests.*:PowerInfoTests.*:CameraTests.*'
The CI job that covers this layer is dormant on ordinary development pushes. devices-tests.yml triggers only on master/main, and CNA's default branch is develop. Do not read a green branch as evidence that the devices suite ran.
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. The two namespaces share the CNA_DEVICES build flag 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