▸
cmake/compile_definitions/linux.cmake
+43
−0
@@ -286,6 +286,49 @@
message(FATAL_ERROR "SUNSHINE_ENABLE_KWIN requires SUNSHINE_ENABLE_WAYLAND — KWin capture disabled")
endif()
# QEMU D-Bus display (capture = qemu)
set(QEMU_FOUND OFF)
if(${SUNSHINE_ENABLE_QEMU})
pkg_check_modules(QEMU_GIO gio-2.0>=2.64 gio-unix-2.0>=2.64)
find_program(GDBUS_CODEGEN_EXECUTABLE gdbus-codegen)
if(QEMU_GIO_FOUND AND GDBUS_CODEGEN_EXECUTABLE)
set(QEMU_FOUND ON)
else()
message(WARNING
"QEMU D-Bus display capture disabled: it needs gio-2.0 >= 2.64, gio-unix-2.0 >= 2.64 and gdbus-codegen")
endif()
endif()
if(QEMU_FOUND)
set(QEMU_DBUS_XML "${CMAKE_SOURCE_DIR}/third-party/qemu-dbus-display/dbus-display1.xml")
set(QEMU_DBUS_GENERATED_DIR "${CMAKE_BINARY_DIR}/generated-src/qemu")
file(MAKE_DIRECTORY "${QEMU_DBUS_GENERATED_DIR}")
# generated at configure time, like the wayland protocols; editing the XML re-runs cmake
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${QEMU_DBUS_XML}")
execute_process(
COMMAND "${GDBUS_CODEGEN_EXECUTABLE}"
--glib-min-required 2.64
--output-directory "${QEMU_DBUS_GENERATED_DIR}"
--interface-prefix org.qemu.
--c-namespace QemuDBus
--generate-c-code dbus-display1
"${QEMU_DBUS_XML}"
RESULT_VARIABLE QEMU_DBUS_CODEGEN_RESULT)
if(NOT QEMU_DBUS_CODEGEN_RESULT EQUAL 0)
message(FATAL_ERROR "gdbus-codegen failed for ${QEMU_DBUS_XML}")
endif()
add_compile_definitions(SUNSHINE_BUILD_QEMU)
list(APPEND PLATFORM_TARGET_FILES
"${QEMU_DBUS_GENERATED_DIR}/dbus-display1.c"
"${QEMU_DBUS_GENERATED_DIR}/dbus-display1.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/capture.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/frame_store.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/frame_store.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/session.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/session.cpp")
endif()
if(NOT ${CUDA_FOUND}
AND NOT ${LIBDRM_FOUND}
AND NOT ${LIBVA_FOUND}
▸
cmake/prep/options.cmake
+2
−0
@@ -76,4 +76,6 @@
"Enable KWin ScreenCast grab if available" ON)
option(SUNSHINE_ENABLE_PORTAL
"Enable XDG portal grab if available" ON)
option(SUNSHINE_ENABLE_QEMU
"Enable QEMU D-Bus display capture (capture = qemu) if available" ON)
endif()
▸
docs/configuration.md
+31
−1
@@ -2195,7 +2195,7 @@
@endcode</td>
</tr>
<tr>
<td rowspan="6">Choices</td>
<td rowspan="7">Choices</td>
<td>nvfbc</td>
<td>Use NVIDIA Frame Buffer Capture to capture direct to GPU memory. This is usually the fastest method for
NVIDIA cards. NvFBC does not have native Wayland support and does not work with XWayland.
@@ -2223,6 +2223,13 @@
@note{Applies to FreeBSD and Linux only.}</td>
</tr>
<tr>
<td>qemu</td>
<td>Stream a QEMU virtual machine started with `-display dbus`, with no software in the guest.
Select the bus with [qemu_dbus_address](#qemu_dbus_address) and the console with
[output_name](#output_name). This method is never selected automatically.
@note{Applies to Linux only.}</td>
</tr>
<tr>
<td>ddx</td>
<td>Use DirectX Desktop Duplication API to capture the display. This is well-supported on Windows machines.
@note{Applies to Windows only.}</td>
@@ -2232,5 +2239,28 @@
<td>(beta feature) Use Windows.Graphics.Capture to capture the display.
@note{Applies to Windows only.}
@attention{This capture method is not compatible with the Sunshine service.}</td>
</tr>
</table>
### qemu_dbus_address
<table>
<tr>
<td>Description</td>
<td colspan="2">
D-Bus address of the bus that a QEMU VM's display is on. Only used when [capture](#capture) is `qemu`.
Use the same address given to QEMU with `-display dbus,addr=`.
@note{Applies to Linux only.}
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">Empty, which uses the session bus.</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
qemu_dbus_address = unix:path=/run/sunshine-qemu/vm0/bus.sock
@endcode</td>
</tr>
</table>
▸
JOURNAL.md
+111
−0
@@ -1,0 +1,111 @@
# Journal (fork-only, not upstreamable)
Working notes for the QEMU D-Bus backend. Keep out of the upstream series (#8).
## 2026-09-12 — Phase 0 (#2)
### Environment
- Host: Ubuntu 24.04.4 on WSL2 (kernel 6.18), 20 cores, `/dev/kvm` present, NVIDIA RTX 4090
(Windows driver 610.88, exposed through `/dev/dxg` and `/usr/lib/wsl/lib`, which include
`libcuda` and `libnvidia-encode`). CUDA toolkits 12.8 and 13.2 are in `/usr/local`.
- WSL2 has **no DRM render node** (`/dev/dri` is absent): no VAAPI, no GBM, and no DMABUF export.
QEMU refuses `-device virtio-vga-gl -display dbus,gl=on` with "egl: no drm render node
available". So the DMABUF zero-copy path (#3) can't run here; NVENC encoding of the shm path can.
An earlier version of these notes wrongly said this host has no GPU because `/dev/dri` was missing.
- Toolchain: upstream needs **gcc-14** on Ubuntu 24.04 (`scripts/linux_build.sh` picks it;
`src/platform/linux/kmsgrab.cpp` uses `std::ranges::to`). The default gcc 13 fails to build a
clean upstream tree. Configure with `CC=gcc-14 CXX=g++-14`.
- CUDA isn't installed: configure with `-DCUDA_FAIL_ON_MISSING=OFF`.
- Distro QEMU is 8.2.2. Its D-Bus display has no `Listener.Unix.Map` and no
`Listener.Unix.ScanoutDMABUF2` (checked the strings in `ui-dbus.so`), so it only sends
`Scanout`/`Update` pixel copies. Built QEMU 11.1.1 from the release tarball into
`~/.cache/sunshine-qemu/qemu-install` with
`--target-list=x86_64-softmmu --enable-dbus-display --enable-kvm --enable-slirp --enable-opengl --enable-modules`.
### Baseline (clean upstream `dd7a1f7`, `SUNSHINE_ENABLE_QEMU=OFF`)
`xvfb-run -a cmake-build-noqemu/tests/test_sunshine`: 569 tests, 555 passed, 14 skipped,
**exit code 1**. The failure is `PlatformTestSuite::SetUpTestSuite` (`tests/tests_common.h:162`):
`platf::init()` logs "Unable to initialize capture method" on WSL2 (no KMS, portal interfaces
missing, X11 capture unavailable under Xvfb here). That makes the `MouseHIDTest`, audio and video
platform suites skip and the binary exit 1. Not caused by this work.
### Decisions and deviations from the issue text
- **Console selection uses Sunshine's existing `output_name`**, not a new `qemu_console` key.
`platf::display_names()` returns the graphical console ids, so `output_name = 1` (or a label)
selects a console exactly like it selects a monitor for other backends. One fewer config key for
upstream review; an unknown name falls back to the first graphical console with a warning.
- **Bindings are generated at configure time** (`execute_process`, like `GEN_WAYLAND`), with the XML
in `CMAKE_CONFIGURE_DEPENDS`. A build-time `add_custom_command` would not propagate to the
`test_sunshine` target in `tests/` without extra custom targets.
- VM gone / listener closed / scanout size change all return `capture_e::reinit`. `video.cpp` then
re-creates the display in a retry loop, so a VM restart doesn't end the stream.
- Frames are pushed only when QEMU reports damage; the encoder's minimum-FPS repeat covers static
screens. `img_t::frame_timestamp` is the receipt time of the QEMU call, so Sunshine's existing
`frame_processing_latency` (logged at debug and sent to Moonlight in each frame header) measures
"listener callback receipt → packet out" for REQ-NFR-001.
- Connection setup and the listener handshake have a deadline (a watchdog cancels the
`GCancellable`). Without it, a `qemu_dbus_address` that accepts but never authenticates hangs
Sunshine forever.
- **Two GLib threads per session.** After replying to `RegisterListener`, QEMU makes *synchronous*
calls on the new listener (property `GetAll`, `ScanoutMap`). With one thread, a second
`RegisterListener` issued while QEMU waited on the first listener deadlocked both processes until
the call timeout (found by `QemuSessionTest.SupportsMultipleListenersAndReregistration`). The bus
thread makes blocking calls; listener connections are dispatched on a thread that never blocks on
QEMU.
### Results
- Unit tests: 37 QEMU tests (session against a fake QEMU on a private dbus-daemon, frame store,
capture, REQ-CMP-001 "never connects unless selected"), stable across `--gtest_repeat=5`.
- E2E (`tests/e2e/qemu/e2e_stream.sh`), KVM, software encoding:
- QEMU 11.1.1, shared memory map path: PASS, quadrants `[253,0,0] [0,254,0] [0,0,254] [255,255,255]`.
- QEMU 8.2.2 (distro), D-Bus message path: PASS, same colors.
- Red check: the same harness against a Sunshine built without the backend fails at `launch`
with HTTP 503 "Failed to initialize video capture/encoding".
- The harness was written after the capture spike rather than strictly first; the red check above
is the evidence that it fails without the feature.
### REQ-NFR-001 measurements (host processing latency: QEMU call receipt → encoded frame sent)
Release build, gcc-14, 20-core host under WSL2, KVM, guest framebuffer 640x400 scaled by
Sunshine, ~330 samples per run over 18 s. NVENC runs use a CUDA build
(`-DCMAKE_CUDA_COMPILER=/usr/local/cuda-13.2/bin/nvcc`, `encoder = nvenc`, `h264_nvenc` with the
RAM→CUDA upload) on the RTX 4090.
| Transport (QEMU) | Encoder | Stream | p50 | p95 |
|---------------------------------|-----------------|--------------|---------|---------|
| Shared memory map (11.1.1) | NVENC | 1920x1080@60 | 11.1 ms | 20.4 ms |
| D-Bus messages (8.2.2) | NVENC | 1920x1080@60 | 12.2 ms | 19.5 ms |
| Shared memory map (11.1.1) | software x264 | 1920x1080@60 | 14.1 ms | 21.4 ms |
| D-Bus messages (8.2.2) | software x264 | 1920x1080@60 | 14.1 ms | 21.1 ms |
| Shared memory map, Debug build | software x264 | 1280x800@30 | 17.2 ms | 32.9 ms |
| D-Bus messages, Debug build | software x264 | 1280x800@30 | 20.2 ms | 34.6 ms |
The transports tie because the guest framebuffer is small.
**Capture pacing dominates, not encoding.** The capture loop wakes on a fixed tick of one frame
interval (16.7 ms at 60 fps) and copies whatever damage arrived since the last tick. Damage lands
uniformly within the interval, so it waits 0–16.7 ms (p50 ≈ 8 ms, p95 ≈ 16 ms) before the copy.
Add ~3 ms for NVENC and that predicts p50 ≈ 11 ms and p95 ≈ 19–20 ms, which matches the
measurements. Hardware encoding only saves the 2–3 ms that x264 spends. Waking the capture thread on
damage (a condition variable with the next frame deadline as timeout) should remove most of the
wait. That belongs with the pacing work in #3.
The draft "p95 ≤ 10 ms on the DMABUF path" can't be measured here, because WSL2 has no DRM render
node for QEMU's GL display. It stays for #3 on native Linux.
Seen during encoder probing with the CUDA build, not caused by this backend: "cuda::cuda_t doesn't
support any format other than AV_PIX_FMT_NV12 and AV_PIX_FMT_YUV444P" (the RAM→CUDA upload rejects
10-bit probes, as it does for x11 capture). The 8-bit H.264 stream is unaffected.
### Observations for later phases
- Encoder probing creates many short-lived displays; `qemu::shared_session` only caches a
`weak_ptr`, so each probe reconnects (~2-5 ms each). Cheap, but a short keep-alive would avoid it.
- `platf::init()` runs `verify_qemu()` at Sunshine startup, so QEMU must already own `org.qemu`
when Sunshine starts. Relevant for the systemd/libvirt ordering in #6.
- In the E2E runs, PulseAudio isn't running ("Failed to create client: Daemon not running") and
libvirtualhid gamepads are unavailable; video streaming is unaffected. #4 and #5 replace both.
▸
src/config.cpp
+2
−0
@@ -780,6 +780,7 @@
{}, // encoder
{}, // adapter_name
{}, // output_name
{}, // qemu_dbus_address
{
video_t::dd_t::config_option_e::disabled, // configuration_option
@@ -1698,6 +1699,7 @@
string_f(vars, "encoder", video.encoder);
string_f(vars, "adapter_name", video.adapter_name);
string_f(vars, "output_name", video.output_name);
string_f(vars, "qemu_dbus_address", video.qemu_dbus_address);
generic_f(vars, "dd_configuration_option", video.dd.configuration_option, dd::config_option_from_view);
generic_f(vars, "dd_resolution_option", video.dd.resolution_option, dd::resolution_option_from_view);
▸
src/config.h
+1
−0
@@ -137,6 +137,7 @@
std::string encoder; ///< Encoder backend name selected by configuration.
std::string adapter_name; ///< Display adapter name selected in configuration.
std::string output_name; ///< Display output name selected in configuration.
std::string qemu_dbus_address; ///< D-Bus address of the bus QEMU's display is on; empty uses the session bus.
/**
* @brief Display-device integration settings.
▸
src/platform/linux/misc.cpp
+28
−0
@@ -1125,6 +1125,9 @@
#ifdef SUNSHINE_BUILD_PORTAL
PORTAL, ///< XDG PORTAL
#endif
#ifdef SUNSHINE_BUILD_QEMU
QEMU, ///< QEMU D-Bus display
#endif
MAX_FLAGS ///< The maximum number of flags
};
} // namespace source
@@ -1205,10 +1208,21 @@
}
#endif
#ifdef SUNSHINE_BUILD_QEMU
std::vector<std::string> qemu_display_names();
std::shared_ptr<display_t> qemu_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config);
bool verify_qemu();
#endif
/**
* @brief List display names accepted by the selected capture backend.
*/
std::vector<std::string> display_names(mem_type_e hwdevice_type) {
#ifdef SUNSHINE_BUILD_QEMU
if (sources[source::QEMU]) {
return qemu_display_names();
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
// display using NvFBC only supports mem_type_e::cuda
if (sources[source::NVFBC] && hwdevice_type == mem_type_e::cuda) {
@@ -1275,6 +1289,13 @@
drop_elevated_privileges(false);
}
#ifdef SUNSHINE_BUILD_QEMU
if (sources[source::QEMU]) {
BOOST_LOG(info) << "Screencasting with QEMU D-Bus display"sv;
return qemu_display(hwdevice_type, display_name, config);
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
if (sources[source::NVFBC] && hwdevice_type == mem_type_e::cuda) {
BOOST_LOG(info) << "Screencasting with NvFBC"sv;
@@ -1371,5 +1392,12 @@
#ifdef SUNSHINE_BUILD_KWIN
if (((config::video.capture.empty() && sources.none()) || config::video.capture == "kwin") && verify_kwin()) {
sources[source::KWIN] = true;
}
#endif
#ifdef SUNSHINE_BUILD_QEMU
// Only used when explicitly selected; a VM display is never auto-detected.
if (config::video.capture == "qemu" && verify_qemu()) {
sources[source::QEMU] = true;
}
#endif
▸
src/platform/linux/qemu/capture.cpp
+270
−0
@@ -1,0 +1,270 @@
/**
* @file src/platform/linux/qemu/capture.cpp
* @brief Definitions for the QEMU D-Bus display capture backend (`capture = qemu`).
*/
// standard includes
#include <cstring>
#include <mutex>
// local includes
#include "frame_store.h"
#include "session.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/platform/linux/misc.h"
#include "src/video.h"
#ifdef SUNSHINE_BUILD_VAAPI
#include "src/platform/linux/vaapi.h"
#endif
#ifdef SUNSHINE_BUILD_CUDA
#include "src/platform/linux/cuda.h"
#endif
using namespace std::literals;
namespace qemu {
/**
* @brief System-memory image owned by the QEMU capture backend.
*/
struct img_t: public platf::img_t {
~img_t() override {
delete[] data;
data = nullptr;
}
};
/**
* @brief Display backend that streams a QEMU console from shared memory scanouts.
*/
class display_t: public platf::display_t {
public:
/**
* @brief Create an uninitialized display.
*
* @param mem_type Memory type the encoder expects.
*/
explicit display_t(platf::mem_type_e mem_type):
mem_type {mem_type} {
}
/**
* @brief Connect to QEMU, register a listener on the selected console, and wait for a frame.
*
* @param display_name Console id or label; empty selects the first graphical console.
* @param config Stream configuration.
* @return 0 on success, -1 on failure.
*/
int init(const std::string &display_name, const ::video::config_t &config) {
delay = ::video::capture_frame_interval(config);
session = shared_session(config::video.qemu_dbus_address);
if (!session) {
return -1;
}
auto vm = session->vm();
auto console = find_console(vm, display_name);
if (!console) {
BOOST_LOG(warning) << "qemu: console ["sv << display_name << "] not found, using the first graphical console"sv;
console = find_console(vm, "");
}
if (!console) {
BOOST_LOG(error) << "qemu: VM ["sv << vm.name << "] has no graphical console"sv;
return -1;
}
store = std::make_shared<frame_store_t>();
registration = session->register_listener(console->id, store);
if (!registration) {
return -1;
}
// QEMU sends the current surface right after registration
if (store->wait_for_frame(2s)) {
width = store->width();
height = store->height();
} else {
BOOST_LOG(warning) << "qemu: no scanout received yet; using the console size"sv;
width = (int) console->width;
height = (int) console->height;
}
if (width <= 0 || height <= 0) {
BOOST_LOG(error) << "qemu: console "sv << console->id << " has no size"sv;
return -1;
}
env_width = width;
env_height = height;
logical_width = width;
logical_height = height;
env_logical_width = width;
env_logical_height = height;
BOOST_LOG(info) << "qemu: streaming VM ["sv << vm.name << "] console "sv << console->id << " ["sv << console->label << "] at "sv << width << 'x' << height;
return 0;
}
platf::capture_e capture(const push_captured_image_cb_t &push_captured_image_cb, const pull_free_image_cb_t &pull_free_image_cb, bool *cursor) override {
auto next_frame = std::chrono::steady_clock::now();
sleep_overshoot_logger.reset();
while (true) {
platf::handle_pacing(next_frame, delay, sleep_overshoot_logger);
std::shared_ptr<platf::img_t> img_out;
auto status = snapshot(pull_free_image_cb, img_out);
switch (status) {
case platf::capture_e::reinit:
case platf::capture_e::error:
case platf::capture_e::interrupted:
return status;
case platf::capture_e::timeout:
if (!push_captured_image_cb(std::move(img_out), false)) {
return platf::capture_e::ok;
}
break;
case platf::capture_e::ok:
if (!push_captured_image_cb(std::move(img_out), true)) {
return platf::capture_e::ok;
}
break;
default:
BOOST_LOG(error) << "Unrecognized capture status ["sv << (int) status << ']';
return status;
}
}
}
/**
* @brief Copy the latest frame if the guest changed it.
*
* @param pull_free_image_cb Callback providing an image to fill.
* @param img_out Filled image on `ok`.
* @return `ok` with a new frame, `timeout` when nothing changed, `reinit` when the size
* changed or QEMU went away, or `interrupted`.
*/
platf::capture_e snapshot(const pull_free_image_cb_t &pull_free_image_cb, std::shared_ptr<platf::img_t> &img_out) {
if (!session->alive() || !store->connected() || store->width() != width || store->height() != height) {
return platf::capture_e::reinit;
}
if (store->sequence() == copied_sequence) {
return platf::capture_e::timeout;
}
if (!pull_free_image_cb(img_out)) {
return platf::capture_e::interrupted;
}
std::chrono::steady_clock::time_point timestamp;
switch (store->copy_if_newer(copied_sequence, width, height, img_out->data, timestamp)) {
case frame_status_e::new_frame:
img_out->frame_timestamp = timestamp;
damage_to_capture_logger.first_point(timestamp);
damage_to_capture_logger.second_point_now_and_log();
return platf::capture_e::ok;
case frame_status_e::unchanged:
return platf::capture_e::timeout;
case frame_status_e::size_changed:
case frame_status_e::disconnected:
default:
return platf::capture_e::reinit;
}
}
std::shared_ptr<platf::img_t> alloc_img() override {
auto img = std::make_shared<qemu::img_t>();
img->width = width;
img->height = height;
img->pixel_pitch = 4;
img->row_pitch = img->pixel_pitch * width;
img->data = new std::uint8_t[(std::size_t) height * img->row_pitch];
return img;
}
int dummy_img(platf::img_t *img) override {
if (!img) {
return -1;
}
std::memset(img->data, 0, (std::size_t) img->height * img->row_pitch);
return 0;
}
std::unique_ptr<platf::avcodec_encode_device_t> make_avcodec_encode_device(platf::pix_fmt_e pix_fmt) override {
#ifdef SUNSHINE_BUILD_VAAPI
if (mem_type == platf::mem_type_e::vaapi) {
return va::make_avcodec_encode_device(width, height, false);
}
#endif
#ifdef SUNSHINE_BUILD_CUDA
if (mem_type == platf::mem_type_e::cuda) {
return cuda::make_avcodec_encode_device(width, height, false);
}
#endif
return std::make_unique<platf::avcodec_encode_device_t>();
}
private:
platf::mem_type_e mem_type; ///< Memory type the encoder expects.
std::chrono::nanoseconds delay {}; ///< Capture interval for the client frame rate.
std::shared_ptr<session_t> session; ///< Session to the VM.
std::shared_ptr<frame_store_t> store; ///< Frame reconstructed from listener calls.
std::unique_ptr<listener_registration_t> registration; ///< Keeps the listener registered.
std::uint64_t copied_sequence {0}; ///< Store sequence of the last copied frame.
logging::time_delta_periodic_logger damage_to_capture_logger {debug, "qemu: damage received to frame captured"}; ///< Latency from QEMU's call to the capture copy.
};
} // namespace qemu
namespace platf {
/**
* @brief Check whether the QEMU backend was selected and QEMU is reachable.
* @details Never connects unless `capture = qemu`, so other backends are unaffected.
*
* @return True when `capture = qemu` and a QEMU display was found.
*/
bool verify_qemu() {
if (config::video.capture != "qemu") {
return false;
}
return qemu::shared_session(config::video.qemu_dbus_address) != nullptr;
}
/**
* @brief List the graphical consoles of the VM.
*
* @return Console ids, or an empty list when QEMU is unreachable.
*/
std::vector<std::string> qemu_display_names() {
auto session = qemu::shared_session(config::video.qemu_dbus_address);
if (!session) {
return {};
}
auto names = qemu::graphic_console_names(session->vm());
for (const auto &name : names) {
BOOST_LOG(info) << "qemu: found console "sv << name;
}
return names;
}
/**
* @brief Create a QEMU display capture backend.
*
* @param hwdevice_type Memory type the encoder expects.
* @param display_name Console id or label.
* @param config Stream configuration.
* @return Display backend, or nullptr on failure.
*/
std::shared_ptr<display_t> qemu_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config) {
if (hwdevice_type != mem_type_e::system && hwdevice_type != mem_type_e::vaapi && hwdevice_type != mem_type_e::cuda) {
BOOST_LOG(error) << "qemu: could not initialize display with the given hw device type"sv;
return nullptr;
}
auto display = std::make_shared<qemu::display_t>(hwdevice_type);
if (display->init(display_name, config)) {
return nullptr;
}
return display;
}
} // namespace platf
▸
src/platform/linux/qemu/frame_store.cpp
+287
−0
@@ -1,0 +1,287 @@
/**
* @file src/platform/linux/qemu/frame_store.cpp
* @brief Definitions for the shared-memory frame store of the QEMU capture backend.
*/
// class header include
#include "frame_store.h"
// standard includes
#include <algorithm>
#include <cstring>
// platform includes
#include <sys/mman.h>
#include <sys/stat.h>
// local includes
#include "src/logging.h"
using namespace std::literals;
namespace qemu {
namespace {
/**
* @brief Report whether a pixman format is 32 bpp with red and blue swapped relative to BGRX.
*
* @param format Pixman format code.
* @return True for `x8b8g8r8` and `a8b8g8r8`.
*/
bool is_rgbx(std::uint32_t format) {
return format == pixman_format::x8b8g8r8 || format == pixman_format::a8b8g8r8;
}
/**
* @brief Report whether the frame store can convert a pixman format.
*
* @param format Pixman format code.
* @return True for the supported 32 bpp formats.
*/
bool is_supported(std::uint32_t format) {
return format == pixman_format::x8r8g8b8 || format == pixman_format::a8r8g8b8 || is_rgbx(format);
}
/**
* @brief Log an unsupported format once per format code.
*
* @param format Pixman format code.
*/
void log_unsupported(std::uint32_t format) {
static std::mutex logged_mutex;
static std::vector<std::uint32_t> logged;
std::lock_guard lock {logged_mutex};
if (std::ranges::find(logged, format) == logged.end()) {
logged.push_back(format);
BOOST_LOG(warning) << "qemu: unsupported pixman format 0x"sv << std::hex << format << std::dec << "; ignoring scanout"sv;
}
}
/**
* @brief Check that a pixel buffer covers a rectangle.
*
* @param available Bytes available.
* @param width Rectangle width.
* @param height Rectangle height.
* @param stride Bytes per row.
* @return True when the buffer is large enough and the stride fits the width.
*/
bool covers(std::size_t available, std::int64_t width, std::int64_t height, std::int64_t stride) {
if (width <= 0 || height <= 0 || stride < width * 4) {
return false;
}
return (std::size_t) (stride * (height - 1) + width * 4) <= available;
}
} // namespace
frame_store_t::~frame_store_t() {
std::lock_guard lock {mutex};
unmap_locked();
}
void frame_store_t::scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) {
if (!is_supported(format)) {
log_unsupported(format);
return;
}
if (!covers(data.size(), width, height, stride)) {
BOOST_LOG(warning) << "qemu: scanout data doesn't cover "sv << width << 'x' << height << " with stride "sv << stride;
return;
}
std::lock_guard lock {mutex};
unmap_locked();
resize_locked((int) width, (int) height);
blit_locked(0, 0, (int) width, (int) height, data.data(), stride, format);
touch_locked();
}
void frame_store_t::update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) {
if (!is_supported(format)) {
log_unsupported(format);
return;
}
if (!covers(data.size(), width, height, stride)) {
BOOST_LOG(warning) << "qemu: update data doesn't cover "sv << width << 'x' << height << " with stride "sv << stride;
return;
}
std::lock_guard lock {mutex};
if (frame_width == 0 || map_addr) {
return;
}
// clip the rectangle to the frame, skipping the source rows and columns that fall outside
int x0 = std::max(x, 0);
int y0 = std::max(y, 0);
int x1 = std::min<std::int64_t>((std::int64_t) x + width, frame_width);
int y1 = std::min<std::int64_t>((std::int64_t) y + height, frame_height);
if (x0 >= x1 || y0 >= y1) {
return;
}
auto src = data.data() + (std::size_t) (y0 - y) * stride + (std::size_t) (x0 - x) * 4;
blit_locked(x0, y0, x1 - x0, y1 - y0, src, stride, format);
touch_locked();
}
void frame_store_t::scanout_map(fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) {
if (fd.get() < 0) {
BOOST_LOG(warning) << "qemu: ScanoutMap without a descriptor"sv;
return;
}
if (!is_supported(format)) {
log_unsupported(format);
return;
}
struct stat st {};
if (fstat(fd.get(), &st) != 0 || !covers(st.st_size > offset ? (std::size_t) st.st_size - offset : 0, width, height, stride)) {
BOOST_LOG(warning) << "qemu: ScanoutMap descriptor doesn't cover "sv << width << 'x' << height << " at offset "sv << offset;
return;
}
std::size_t size = (std::size_t) offset + (std::size_t) stride * height;
size = std::min<std::size_t>(size, st.st_size);
auto addr = mmap(nullptr, size, PROT_READ, MAP_SHARED, fd.get(), 0);
if (addr == MAP_FAILED) {
BOOST_LOG(warning) << "qemu: couldn't map scanout: "sv << std::strerror(errno);
return;
}
std::lock_guard lock {mutex};
unmap_locked();
map_fd = std::move(fd);
map_addr = (const std::uint8_t *) addr;
map_size = size;
map_offset = offset;
map_stride = stride;
map_format = format;
resize_locked((int) width, (int) height);
blit_locked(0, 0, (int) width, (int) height, map_addr + map_offset, map_stride, map_format);
touch_locked();
}
void frame_store_t::update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
std::lock_guard lock {mutex};
if (!map_addr) {
return;
}
int x0 = std::max(x, 0);
int y0 = std::max(y, 0);
int x1 = std::min<std::int64_t>((std::int64_t) x + width, frame_width);
int y1 = std::min<std::int64_t>((std::int64_t) y + height, frame_height);
if (x0 >= x1 || y0 >= y1) {
return;
}
auto src = map_addr + map_offset + (std::size_t) y0 * map_stride + (std::size_t) x0 * 4;
blit_locked(x0, y0, x1 - x0, y1 - y0, src, map_stride, map_format);
touch_locked();
}
void frame_store_t::disable() {
std::lock_guard lock {mutex};
std::ranges::fill(pixels, 0);
if (frame_width > 0) {
touch_locked();
}
}
void frame_store_t::disconnected() {
std::lock_guard lock {mutex};
is_disconnected = true;
frame_ready.notify_all();
}
bool frame_store_t::wait_for_frame(std::chrono::milliseconds timeout) {
std::unique_lock lock {mutex};
frame_ready.wait_for(lock, timeout, [this]() {
return change_sequence > 0 || is_disconnected;
});
return change_sequence > 0 && !is_disconnected;
}
int frame_store_t::width() const {
std::lock_guard lock {mutex};
return frame_width;
}
int frame_store_t::height() const {
std::lock_guard lock {mutex};
return frame_height;
}
bool frame_store_t::connected() const {
std::lock_guard lock {mutex};
return !is_disconnected;
}
std::uint64_t frame_store_t::sequence() const {
std::lock_guard lock {mutex};
return change_sequence;
}
frame_status_e frame_store_t::copy_if_newer(std::uint64_t &last_sequence, int width, int height, std::uint8_t *dst, std::chrono::steady_clock::time_point ×tamp) {
std::lock_guard lock {mutex};
if (is_disconnected) {
return frame_status_e::disconnected;
}
if (width != frame_width || height != frame_height) {
return frame_status_e::size_changed;
}
if (change_sequence == last_sequence) {
return frame_status_e::unchanged;
}
std::memcpy(dst, pixels.data(), pixels.size());
last_sequence = change_sequence;
timestamp = change_time;
return frame_status_e::new_frame;
}
void frame_store_t::resize_locked(int new_width, int new_height) {
const bool mapped = map_addr != nullptr;
if (new_width != frame_width || new_height != frame_height || mapped != logged_mapped) {
BOOST_LOG(info) << "qemu: scanout "sv << new_width << 'x' << new_height << (mapped ? " via shared memory map"sv : " via D-Bus messages"sv);
logged_mapped = mapped;
}
frame_width = new_width;
frame_height = new_height;
pixels.assign((std::size_t) new_width * new_height * 4, 0);
}
void frame_store_t::blit_locked(int x, int y, int w, int h, const std::uint8_t *src, std::uint32_t src_stride, std::uint32_t format) {
const auto dst_stride = (std::size_t) frame_width * 4;
const bool swap = is_rgbx(format);
for (int row = 0; row < h; ++row) {
auto s = src + (std::size_t) row * src_stride;
auto d = pixels.data() + (std::size_t) (y + row) * dst_stride + (std::size_t) x * 4;
if (!swap) {
std::memcpy(d, s, (std::size_t) w * 4);
continue;
}
for (int col = 0; col < w; ++col, s += 4, d += 4) {
d[0] = s[2];
d[1] = s[1];
d[2] = s[0];
d[3] = s[3];
}
}
}
void frame_store_t::touch_locked() {
change_sequence += 1;
change_time = std::chrono::steady_clock::now();
frame_ready.notify_all();
}
void frame_store_t::unmap_locked() {
if (map_addr) {
munmap((void *) map_addr, map_size);
map_addr = nullptr;
map_size = 0;
}
map_fd = fd_t {};
}
} // namespace qemu
▸
src/platform/linux/qemu/frame_store.h
+148
−0
@@ -1,0 +1,148 @@
/**
* @file src/platform/linux/qemu/frame_store.h
* @brief Declarations for the shared-memory frame store of the QEMU capture backend.
*/
#pragma once
// standard includes
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <optional>
#include <span>
#include <vector>
// local includes
#include "session.h"
namespace qemu {
/**
* @brief Result of polling the frame store for a new frame.
*/
enum class frame_status_e {
new_frame, ///< A frame newer than the caller's sequence number was copied.
unchanged, ///< Nothing changed since the caller's sequence number.
size_changed, ///< The frame size differs from the caller's size.
disconnected, ///< QEMU closed the listener connection.
};
/**
* @brief Reconstructs the guest framebuffer from QEMU listener calls.
* @details Listener calls arrive on the session thread; the capture thread copies frames out. The
* stored frame is always 4 bytes per pixel in B, G, R, X order with a stride of `4 * width`,
* which is the layout Sunshine's system-memory images use.
*/
class frame_store_t: public display_listener_t {
public:
frame_store_t() = default;
~frame_store_t() override;
frame_store_t(const frame_store_t &) = delete;
frame_store_t &operator=(const frame_store_t &) = delete;
void scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override;
void update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override;
void scanout_map(fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) override;
void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override;
void disable() override;
void disconnected() override;
/**
* @brief Wait until the first scanout arrived.
*
* @param timeout Maximum time to wait.
* @return True when a frame is available.
*/
bool wait_for_frame(std::chrono::milliseconds timeout);
/**
* @brief Current frame width.
*
* @return Width in pixels, 0 before the first scanout.
*/
[[nodiscard]] int width() const;
/**
* @brief Current frame height.
*
* @return Height in pixels, 0 before the first scanout.
*/
[[nodiscard]] int height() const;
/**
* @brief Report whether QEMU still has the listener connection open.
*
* @return False once QEMU closed the connection.
*/
[[nodiscard]] bool connected() const;
/**
* @brief Sequence number of the latest change.
*
* @return Monotonic counter, 0 before the first scanout.
*/
[[nodiscard]] std::uint64_t sequence() const;
/**
* @brief Copy the frame if it changed since the caller last copied it.
*
* @param last_sequence Sequence of the caller's last copy; updated on `new_frame`.
* @param width Width of the destination buffer.
* @param height Height of the destination buffer.
* @param dst Destination with `4 * width` bytes per row.
* @param timestamp Receipt time of the call that produced the copied frame; set on `new_frame`.
* @return Poll result.
*/
frame_status_e copy_if_newer(std::uint64_t &last_sequence, int width, int height, std::uint8_t *dst, std::chrono::steady_clock::time_point ×tamp);
private:
/**
* @brief Resize the frame, logging size or transport changes.
*
* @param new_width New width in pixels.
* @param new_height New height in pixels.
*/
void resize_locked(int new_width, int new_height);
/**
* @brief Convert and copy a rectangle of source pixels into the frame.
*
* @param x Destination left edge.
* @param y Destination top edge.
* @param w Rectangle width.
* @param h Rectangle height.
* @param src Pointer to the top-left source pixel of the rectangle.
* @param src_stride Source bytes per row.
* @param format Source pixman format; must be supported.
*/
void blit_locked(int x, int y, int w, int h, const std::uint8_t *src, std::uint32_t src_stride, std::uint32_t format);
/**
* @brief Mark the frame as changed now.
*/
void touch_locked();
/**
* @brief Unmap the current shared memory scanout.
*/
void unmap_locked();
mutable std::mutex mutex; ///< Guards all state below.
std::condition_variable frame_ready; ///< Signaled when a frame or disconnect arrives.
std::vector<std::uint8_t> pixels; ///< BGRX frame, `4 * frame_width` bytes per row.
int frame_width {0}; ///< Frame width in pixels.
int frame_height {0}; ///< Frame height in pixels.
std::uint64_t change_sequence {0}; ///< Incremented on every change.
std::chrono::steady_clock::time_point change_time; ///< Receipt time of the latest change.
bool is_disconnected {false}; ///< Whether QEMU closed the listener connection.
bool logged_mapped {false}; ///< Whether the last logged scanout used the shared memory map.
fd_t map_fd; ///< Shared memory descriptor of the current map scanout.
const std::uint8_t *map_addr {nullptr}; ///< Mapping of `map_fd`.
std::size_t map_size {0}; ///< Length of the mapping.
std::uint32_t map_offset {0}; ///< Offset of the first pixel in the mapping.
std::uint32_t map_stride {0}; ///< Bytes per row in the mapping.
std::uint32_t map_format {0}; ///< Pixman format of the mapping.
};
} // namespace qemu
▸
src/platform/linux/qemu/session.cpp
+744
−0
@@ -1,0 +1,744 @@
/**
* @file src/platform/linux/qemu/session.cpp
* @brief Definitions for the QEMU D-Bus display session.
*/
// class header include
#include "session.h"
// standard includes
#include <algorithm>
#include <atomic>
#include <charconv>
#include <condition_variable>
#include <cstring>
#include <functional>
#include <future>
#include <map>
#include <mutex>
#include <thread>
#include <utility>
// platform includes
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <sys/socket.h>
#include <unistd.h>
// local includes
#include "src/logging.h"
// generated includes
extern "C" {
#include "qemu/dbus-display1.h"
}
using namespace std::literals;
namespace qemu {
namespace {
constexpr auto bus_name = "org.qemu"; ///< Well-known name QEMU owns on the bus.
constexpr auto vm_path = "/org/qemu/Display1/VM"; ///< Object path of the VM interface.
constexpr auto listener_path = "/org/qemu/Display1/Listener"; ///< Object path QEMU calls on the listener connection.
constexpr auto unix_map_interface = "org.qemu.Display1.Listener.Unix.Map"; ///< Shared memory listener interface.
/**
* @brief Convert a NULL-terminated string vector to a vector of strings.
*
* @param strv String vector, may be null.
* @return Copied strings.
*/
std::vector<std::string> to_strings(const gchar *const *strv) {
std::vector<std::string> result;
for (auto it = strv; it && *it; ++it) {
result.emplace_back(*it);
}
return result;
}
/**
* @brief Read a string property that may be missing on old QEMU releases.
*
* @param value Property value, may be null.
* @return The value, or an empty string.
*/
std::string to_string(const gchar *value) {
return value ? value : "";
}
/**
* @brief Cancels a GCancellable when a deadline passes, for blocking GDBus calls without a timeout.
* @details Connection setup and authentication have no timeout of their own; a socket that
* accepts but never answers would block forever.
*/
class deadline_t {
public:
/**
* @brief Start the watchdog.
*
* @param timeout Time after which the cancellable is cancelled.
*/
explicit deadline_t(std::chrono::milliseconds timeout):
cancellable {g_cancellable_new()} {
watchdog = std::thread([this, timeout]() {
std::unique_lock lock {mutex};
if (!finished.wait_for(lock, timeout, [this]() {
return done;
})) {
g_cancellable_cancel(cancellable);
}
});
}
~deadline_t() {
{
std::lock_guard lock {mutex};
done = true;
}
finished.notify_all();
watchdog.join();
g_object_unref(cancellable);
}
deadline_t(const deadline_t &) = delete;
deadline_t &operator=(const deadline_t &) = delete;
GCancellable *cancellable; ///< Cancelled when the deadline passes.
private:
std::mutex mutex; ///< Guards `done`.
std::condition_variable finished; ///< Signaled when the guarded work finished.
bool done {false}; ///< Whether the guarded work finished.
std::thread watchdog; ///< Thread waiting for the deadline.
};
/**
* @brief Owns a GLib main context and the thread that runs it.
*/
class loop_thread_t {
public:
loop_thread_t():
context {g_main_context_new()},
loop {g_main_loop_new(context, FALSE)} {
thread = std::thread([this]() {
g_main_context_push_thread_default(context);
g_main_loop_run(loop);
g_main_context_pop_thread_default(context);
});
}
~loop_thread_t() {
post([this]() {
g_main_loop_quit(loop);
});
thread.join();
g_main_loop_unref(loop);
g_main_context_unref(context);
}
loop_thread_t(const loop_thread_t &) = delete;
loop_thread_t &operator=(const loop_thread_t &) = delete;
/**
* @brief Run a function on the loop thread and wait for it to finish.
* @details Runs the function inline when called from the loop thread.
*
* @param fn Function to run.
*/
void invoke(const std::function<void()> &fn) {
if (g_main_context_is_owner(context)) {
fn();
return;
}
std::packaged_task<void()> task {fn};
auto done = task.get_future();
post([&task]() {
task();
});
done.get();
}
private:
/**
* @brief Queue a function on the loop without waiting.
*
* @param fn Function to run; must outlive its execution.
*/
void post(std::function<void()> fn) {
auto source = g_idle_source_new();
g_source_set_priority(source, G_PRIORITY_DEFAULT);
g_source_set_callback(
source,
[](gpointer data) -> gboolean {
(*(std::function<void()> *) data)();
return G_SOURCE_REMOVE;
},
new std::function<void()> {std::move(fn)},
[](gpointer data) {
delete (std::function<void()> *) data;
}
);
g_source_attach(source, context);
g_source_unref(source);
}
GMainContext *context; ///< Context owned by the loop thread.
GMainLoop *loop; ///< Loop running on the thread.
std::thread thread; ///< Thread running the loop.
};
class session_impl_t;
/**
* @brief Peer-to-peer listener connection registered on a console.
*/
class listener_impl_t: public listener_registration_t {
public:
/**
* @brief Create an unconnected registration.
*
* @param session Session that owns the loop thread.
* @param listener Receiver for display calls.
*/
listener_impl_t(std::shared_ptr<session_impl_t> session, std::shared_ptr<display_listener_t> listener);
~listener_impl_t() override;
/**
* @brief Complete the peer-to-peer handshake and export the listener objects.
* @details Must run on the listener thread, right after `RegisterListener` returned.
*
* @param socket_fd Our end of the socket pair.
* @param cancellable Cancels the handshake when the deadline passes.
* @return True when the listener is exported.
*/
bool start(fd_t socket_fd, GCancellable *cancellable);
private:
/**
* @brief Unexport the listener objects and close the connection.
* @details Must run on the listener thread.
*/
void stop();
static gboolean on_scanout(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, guint width, guint height, guint stride, guint format, GVariant *data, gpointer self);
static gboolean on_update(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, guint stride, guint format, GVariant *data, gpointer self);
static gboolean on_scanout_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *dmabuf, guint width, guint height, guint stride, guint fourcc, guint64 modifier, gboolean y0_top, gpointer self);
static gboolean on_update_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self);
static gboolean on_disable(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gpointer self);
static gboolean on_mouse_set(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint on, gpointer self);
static gboolean on_cursor_define(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint width, gint height, gint hot_x, gint hot_y, GVariant *data, gpointer self);
static gboolean on_scanout_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *handle, guint offset, guint width, guint height, guint stride, guint format, gpointer self);
static gboolean on_update_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self);
static void on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self);
std::shared_ptr<session_impl_t> session; ///< Keeps the loop thread alive while registered.
std::shared_ptr<display_listener_t> listener; ///< Receiver for display calls.
GDBusConnection *connection {nullptr}; ///< Peer-to-peer connection to QEMU.
QemuDBusDisplay1Listener *skeleton {nullptr}; ///< Exported Listener interface.
QemuDBusDisplay1ListenerUnixMap *map_skeleton {nullptr}; ///< Exported Listener.Unix.Map interface.
bool closed {false}; ///< Whether the peer closed the connection.
};
/**
* @brief GDBus implementation of the session.
*/
class session_impl_t: public session_t, public std::enable_shared_from_this<session_impl_t> {
public:
/**
* @brief Create a session with its loop thread; call connect_bus() next.
*
* @param timeout Timeout applied to each D-Bus call.
*/
explicit session_impl_t(std::chrono::milliseconds timeout):
timeout {timeout},
timeout_ms {(int) timeout.count()} {
}
~session_impl_t() override {
loop.invoke([this]() {
disconnect_bus();
});
}
/**
* @brief Connect to the bus and read the VM and console properties.
*
* @param address Bus address, or empty for the session bus.
* @return True when QEMU was found.
*/
bool connect_bus(const std::string &address) {
bool ok = false;
deadline_t deadline {timeout};
loop.invoke([&]() {
ok = connect_bus_on_loop(address, deadline.cancellable);
if (!ok) {
disconnect_bus();
}
});
return ok;
}
vm_info_t vm() const override {
vm_info_t info;
loop.invoke([&]() {
info.name = to_string(qemu_dbus_display1_vm_get_name(vm_proxy));
info.uuid = to_string(qemu_dbus_display1_vm_get_uuid(vm_proxy));
for (const auto &[id, proxy] : console_proxies) {
console_info_t console;
console.id = id;
console.label = to_string(qemu_dbus_display1_console_get_label(proxy));
console.type = to_string(qemu_dbus_display1_console_get_type_(proxy));
console.head = qemu_dbus_display1_console_get_head(proxy);
console.width = qemu_dbus_display1_console_get_width(proxy);
console.height = qemu_dbus_display1_console_get_height(proxy);
console.interfaces = to_strings(qemu_dbus_display1_console_get_interfaces(proxy));
info.consoles.emplace_back(std::move(console));
}
});
return info;
}
bool alive() const override {
return is_alive;
}
std::unique_ptr<listener_registration_t> register_listener(std::uint32_t console_id, std::shared_ptr<display_listener_t> listener) override {
auto registration = std::make_unique<listener_impl_t>(shared_from_this(), std::move(listener));
bool ok = false;
deadline_t deadline {timeout};
loop.invoke([&]() {
ok = register_listener_on_loop(console_id, *registration, deadline.cancellable);
});
if (!ok) {
return nullptr;
}
return registration;
}
/**
* @brief Thread that owns the bus connection and makes blocking calls to QEMU.
*/
mutable loop_thread_t loop;
/**
* @brief Thread that dispatches listener connections and never blocks on QEMU.
* @details QEMU makes synchronous calls on a new listener (property fetch, ScanoutMap) right
* after replying to RegisterListener. Answering them from the thread that may be blocked in
* another RegisterListener would deadlock both processes until the call timeout.
*/
loop_thread_t listener_loop;
private:
/**
* @brief Connect and read properties; runs on the loop thread.
*
* @param address Bus address, or empty for the session bus.
* @param cancellable Cancels blocking calls when the deadline passes.
* @return True when QEMU was found.
*/
bool connect_bus_on_loop(const std::string &address, GCancellable *cancellable) {
GError *err = nullptr;
std::string resolved = address;
if (resolved.empty()) {
auto session_address = g_dbus_address_get_for_bus_sync(G_BUS_TYPE_SESSION, cancellable, &err);
if (!session_address) {
BOOST_LOG(error) << "qemu: no session bus address: "sv << err->message;
g_clear_error(&err);
return false;
}
resolved = session_address;
g_free(session_address);
}
connection = g_dbus_connection_new_for_address_sync(resolved.c_str(), (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), nullptr, cancellable, &err);
if (!connection) {
BOOST_LOG(error) << "qemu: couldn't connect to D-Bus address ["sv << resolved << "]: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
closed_handler = g_signal_connect(connection, "closed", G_CALLBACK(&session_impl_t::on_bus_closed), this);
vm_proxy = qemu_dbus_display1_vm_proxy_new_sync(connection, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, bus_name, vm_path, cancellable, &err);
if (!vm_proxy) {
BOOST_LOG(error) << "qemu: couldn't create VM proxy: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_proxy_set_default_timeout(G_DBUS_PROXY(vm_proxy), timeout_ms);
auto owner = g_dbus_proxy_get_name_owner(G_DBUS_PROXY(vm_proxy));
if (!owner) {
BOOST_LOG(error) << "qemu: no QEMU owns ["sv << bus_name << "] on ["sv << resolved << ']';
return false;
}
g_free(owner);
auto ids = qemu_dbus_display1_vm_get_console_ids(vm_proxy);
if (!ids) {
BOOST_LOG(error) << "qemu: VM has no ConsoleIDs property; is -display dbus enabled?"sv;
return false;
}
gsize count = 0;
auto id_values = (const guint32 *) g_variant_get_fixed_array(ids, &count, sizeof(guint32));
for (gsize i = 0; i < count; ++i) {
auto path = "/org/qemu/Display1/Console_" + std::to_string(id_values[i]);
auto proxy = qemu_dbus_display1_console_proxy_new_sync(connection, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, bus_name, path.c_str(), cancellable, &err);
if (!proxy) {
BOOST_LOG(warning) << "qemu: skipping console "sv << id_values[i] << ": "sv << err->message;
g_clear_error(&err);
continue;
}
g_dbus_proxy_set_default_timeout(G_DBUS_PROXY(proxy), timeout_ms);
console_proxies.emplace_back(id_values[i], proxy);
}
name_watch = g_bus_watch_name_on_connection(connection, bus_name, G_BUS_NAME_WATCHER_FLAGS_NONE, nullptr, &session_impl_t::on_name_vanished, this, nullptr);
BOOST_LOG(info) << "qemu: connected to VM ["sv << to_string(qemu_dbus_display1_vm_get_name(vm_proxy)) << "] with "sv << console_proxies.size() << " console(s)"sv;
is_alive = true;
return true;
}
/**
* @brief Release every GDBus object; runs on the loop thread.
*/
void disconnect_bus() {
is_alive = false;
if (name_watch) {
g_bus_unwatch_name(name_watch);
name_watch = 0;
}
for (auto &[id, proxy] : console_proxies) {
g_object_unref(proxy);
}
console_proxies.clear();
g_clear_object(&vm_proxy);
if (connection) {
g_signal_handler_disconnect(connection, closed_handler);
g_dbus_connection_close_sync(connection, nullptr, nullptr);
g_clear_object(&connection);
}
}
/**
* @brief Hand one end of a socket pair to `Console.RegisterListener`; runs on the loop thread.
*
* @param console_id Console to register on.
* @param registration Registration to start on success.
* @param cancellable Cancels blocking calls when the deadline passes.
* @return True when the listener is registered.
*/
bool register_listener_on_loop(std::uint32_t console_id, listener_impl_t ®istration, GCancellable *cancellable) {
auto it = std::ranges::find_if(console_proxies, [&](const auto &entry) {
return entry.first == console_id;
});
if (it == console_proxies.end() || !is_alive) {
BOOST_LOG(error) << "qemu: console "sv << console_id << " is not available"sv;
return false;
}
int fds[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) {
BOOST_LOG(error) << "qemu: socketpair failed: "sv << std::strerror(errno);
return false;
}
fd_t ours {fds[0]};
fd_t theirs {fds[1]};
GError *err = nullptr;
auto fd_list = g_unix_fd_list_new();
auto index = g_unix_fd_list_append(fd_list, theirs.get(), &err);
theirs = fd_t {};
if (index < 0) {
BOOST_LOG(error) << "qemu: couldn't attach listener socket: "sv << err->message;
g_clear_error(&err);
g_object_unref(fd_list);
return false;
}
bool ok = qemu_dbus_display1_console_call_register_listener_sync(it->second, g_variant_new_handle(index), G_DBUS_CALL_FLAGS_NONE, timeout_ms, fd_list, nullptr, cancellable, &err);
g_object_unref(fd_list);
if (!ok) {
BOOST_LOG(error) << "qemu: RegisterListener on console "sv << console_id << " failed: "sv << err->message;
g_clear_error(&err);
return false;
}
bool started = false;
listener_loop.invoke([&]() {
started = registration.start(std::move(ours), cancellable);
});
return started;
}
static void on_bus_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self) {
BOOST_LOG(warning) << "qemu: D-Bus connection closed"sv;
((session_impl_t *) self)->is_alive = false;
}
static void on_name_vanished(GDBusConnection *connection, const gchar *name, gpointer self) {
BOOST_LOG(warning) << "qemu: ["sv << name << "] vanished from the bus"sv;
((session_impl_t *) self)->is_alive = false;
}
std::chrono::milliseconds timeout; ///< Deadline for connection setup and registration.
int timeout_ms; ///< Timeout applied to each D-Bus call.
std::atomic<bool> is_alive {false}; ///< Whether QEMU is reachable.
GDBusConnection *connection {nullptr}; ///< Bus connection.
gulong closed_handler {0}; ///< Handler id of the connection's "closed" signal.
guint name_watch {0}; ///< Watch on `org.qemu`.
QemuDBusDisplay1VM *vm_proxy {nullptr}; ///< VM proxy.
std::vector<std::pair<std::uint32_t, QemuDBusDisplay1Console *>> console_proxies; ///< Console proxies in ConsoleIDs order.
};
listener_impl_t::listener_impl_t(std::shared_ptr<session_impl_t> session, std::shared_ptr<display_listener_t> listener):
session {std::move(session)},
listener {std::move(listener)} {
}
listener_impl_t::~listener_impl_t() {
session->listener_loop.invoke([this]() {
stop();
});
}
bool listener_impl_t::start(fd_t socket_fd, GCancellable *cancellable) {
GError *err = nullptr;
auto socket = g_socket_new_from_fd(socket_fd.get(), &err);
if (!socket) {
BOOST_LOG(error) << "qemu: couldn't wrap listener socket: "sv << err->message;
g_clear_error(&err);
return false;
}
socket_fd.release();
auto socket_connection = g_socket_connection_factory_create_connection(socket);
g_object_unref(socket);
// QEMU is the authentication server on this socket. Delay message processing so QEMU's first
// calls (property fetch, ScanoutMap) queue until the listener objects are exported.
connection = g_dbus_connection_new_sync(G_IO_STREAM(socket_connection), nullptr, (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_DELAY_MESSAGE_PROCESSING), nullptr, cancellable, &err);
g_object_unref(socket_connection);
if (!connection) {
BOOST_LOG(error) << "qemu: listener handshake failed: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
skeleton = qemu_dbus_display1_listener_skeleton_new();
const gchar *interfaces[] = {unix_map_interface, nullptr};
qemu_dbus_display1_listener_set_interfaces(skeleton, interfaces);
g_signal_connect(skeleton, "handle-scanout", G_CALLBACK(&listener_impl_t::on_scanout), this);
g_signal_connect(skeleton, "handle-update", G_CALLBACK(&listener_impl_t::on_update), this);
g_signal_connect(skeleton, "handle-scanout-dmabuf", G_CALLBACK(&listener_impl_t::on_scanout_dmabuf), this);
g_signal_connect(skeleton, "handle-update-dmabuf", G_CALLBACK(&listener_impl_t::on_update_dmabuf), this);
g_signal_connect(skeleton, "handle-disable", G_CALLBACK(&listener_impl_t::on_disable), this);
g_signal_connect(skeleton, "handle-mouse-set", G_CALLBACK(&listener_impl_t::on_mouse_set), this);
g_signal_connect(skeleton, "handle-cursor-define", G_CALLBACK(&listener_impl_t::on_cursor_define), this);
map_skeleton = qemu_dbus_display1_listener_unix_map_skeleton_new();
g_signal_connect(map_skeleton, "handle-scanout-map", G_CALLBACK(&listener_impl_t::on_scanout_map), this);
g_signal_connect(map_skeleton, "handle-update-map", G_CALLBACK(&listener_impl_t::on_update_map), this);
if (!g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(skeleton), connection, listener_path, &err) || !g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(map_skeleton), connection, listener_path, &err)) {
BOOST_LOG(error) << "qemu: couldn't export listener: "sv << err->message;
g_clear_error(&err);
stop();
return false;
}
g_signal_connect(connection, "closed", G_CALLBACK(&listener_impl_t::on_closed), this);
g_dbus_connection_start_message_processing(connection);
return true;
}
void listener_impl_t::stop() {
if (skeleton) {
g_signal_handlers_disconnect_by_data(skeleton, this);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(skeleton));
g_clear_object(&skeleton);
}
if (map_skeleton) {
g_signal_handlers_disconnect_by_data(map_skeleton, this);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(map_skeleton));
g_clear_object(&map_skeleton);
}
if (connection) {
g_signal_handlers_disconnect_by_data(connection, this);
if (!closed) {
g_dbus_connection_close_sync(connection, nullptr, nullptr);
}
g_clear_object(&connection);
}
}
gboolean listener_impl_t::on_scanout(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, guint width, guint height, guint stride, guint format, GVariant *data, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(data, &size, 1);
((listener_impl_t *) self)->listener->scanout(width, height, stride, format, {bytes, size});
qemu_dbus_display1_listener_complete_scanout(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_update(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, guint stride, guint format, GVariant *data, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(data, &size, 1);
((listener_impl_t *) self)->listener->update(x, y, width, height, stride, format, {bytes, size});
qemu_dbus_display1_listener_complete_update(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_scanout_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *dmabuf, guint width, guint height, guint stride, guint fourcc, guint64 modifier, gboolean y0_top, gpointer self) {
// DMABUF scanouts need a GL display in QEMU and are handled by the vram capture path (REQ-CAP-003).
static std::once_flag logged;
std::call_once(logged, []() {
BOOST_LOG(warning) << "qemu: ignoring DMABUF scanout; start QEMU without gl=on for shared memory capture"sv;
});
qemu_dbus_display1_listener_complete_scanout_dmabuf(object, invocation, nullptr);
return TRUE;
}
gboolean listener_impl_t::on_update_dmabuf(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self) {
qemu_dbus_display1_listener_complete_update_dmabuf(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_disable(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gpointer self) {
((listener_impl_t *) self)->listener->disable();
qemu_dbus_display1_listener_complete_disable(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_mouse_set(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint x, gint y, gint on, gpointer self) {
((listener_impl_t *) self)->listener->mouse_set(x, y, on != 0);
qemu_dbus_display1_listener_complete_mouse_set(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_cursor_define(QemuDBusDisplay1Listener *object, GDBusMethodInvocation *invocation, gint width, gint height, gint hot_x, gint hot_y, GVariant *data, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(data, &size, 1);
((listener_impl_t *) self)->listener->cursor_define(width, height, hot_x, hot_y, {bytes, size});
qemu_dbus_display1_listener_complete_cursor_define(object, invocation);
return TRUE;
}
gboolean listener_impl_t::on_scanout_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *handle, guint offset, guint width, guint height, guint stride, guint format, gpointer self) {
GError *err = nullptr;
int fd = fd_list ? g_unix_fd_list_get(fd_list, g_variant_get_handle(handle), &err) : -1;
if (fd < 0) {
BOOST_LOG(error) << "qemu: ScanoutMap without a valid descriptor"sv;
if (err) {
g_dbus_method_invocation_return_gerror(invocation, err);
g_clear_error(&err);
} else {
g_dbus_method_invocation_return_error_literal(invocation, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "missing descriptor");
}
return TRUE;
}
((listener_impl_t *) self)->listener->scanout_map(fd_t {fd}, offset, width, height, stride, format);
qemu_dbus_display1_listener_unix_map_complete_scanout_map(object, invocation, nullptr);
return TRUE;
}
gboolean listener_impl_t::on_update_map(QemuDBusDisplay1ListenerUnixMap *object, GDBusMethodInvocation *invocation, gint x, gint y, gint width, gint height, gpointer self) {
((listener_impl_t *) self)->listener->update_map(x, y, width, height);
qemu_dbus_display1_listener_unix_map_complete_update_map(object, invocation);
return TRUE;
}
void listener_impl_t::on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self) {
auto registration = (listener_impl_t *) self;
BOOST_LOG(info) << "qemu: display listener connection closed"sv;
registration->closed = true;
registration->listener->disconnected();
}
} // namespace
fd_t::fd_t(int fd):
fd {fd} {
}
fd_t::fd_t(fd_t &&other) noexcept:
fd {std::exchange(other.fd, -1)} {
}
fd_t &fd_t::operator=(fd_t &&other) noexcept {
if (this != &other) {
if (fd >= 0) {
close(fd);
}
fd = std::exchange(other.fd, -1);
}
return *this;
}
fd_t::~fd_t() {
if (fd >= 0) {
close(fd);
}
}
int fd_t::release() {
return std::exchange(fd, -1);
}
std::shared_ptr<session_t> session_t::connect(const std::string &address, std::chrono::milliseconds timeout) {
auto session = std::make_shared<session_impl_t>(timeout);
if (!session->connect_bus(address)) {
return nullptr;
}
return session;
}
std::shared_ptr<session_t> shared_session(const std::string &address) {
static std::mutex mutex;
static std::weak_ptr<session_t> cached;
static std::string cached_address;
std::lock_guard lock {mutex};
if (auto session = cached.lock(); session && session->alive() && cached_address == address) {
return session;
}
auto session = session_t::connect(address);
cached = session;
cached_address = address;
return session;
}
std::optional<console_info_t> find_console(const vm_info_t &vm, std::string_view name) {
if (name.empty()) {
auto it = std::ranges::find_if(vm.consoles, &console_info_t::is_graphic);
if (it == vm.consoles.end()) {
return std::nullopt;
}
return *it;
}
std::uint32_t id = 0;
auto [end, ec] = std::from_chars(name.data(), name.data() + name.size(), id);
bool is_id = ec == std::errc {} && end == name.data() + name.size();
auto it = std::ranges::find_if(vm.consoles, [&](const console_info_t &console) {
return is_id ? console.id == id : console.label == name;
});
if (it == vm.consoles.end()) {
return std::nullopt;
}
return *it;
}
std::vector<std::string> graphic_console_names(const vm_info_t &vm) {
std::vector<std::string> names;
for (const auto &console : vm.consoles) {
if (console.is_graphic()) {
names.emplace_back(std::to_string(console.id));
}
}
return names;
}
} // namespace qemu
▸
src/platform/linux/qemu/session.h
+286
−0
@@ -1,0 +1,286 @@
/**
* @file src/platform/linux/qemu/session.h
* @brief Declarations for the QEMU D-Bus display session.
* @details The session connects to the D-Bus bus that a QEMU started with `-display dbus` owns
* `org.qemu` on, discovers the VM consoles, and registers peer-to-peer display listeners. It only
* depends on GLib/GIO and POSIX so it can be reused by other Unix hosts.
*/
#pragma once
// standard includes
#include <chrono>
#include <cstdint>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <vector>
namespace qemu {
using namespace std::literals;
/**
* @brief Pixman format codes used by QEMU display scanouts.
* @details Values follow pixman's `PIXMAN_FORMAT(bpp, type, a, r, g, b)` encoding. They are
* defined here so the capture backend does not need to link against pixman.
*/
namespace pixman_format {
inline constexpr std::uint32_t x8r8g8b8 = 0x20020888; ///< 32 bpp, B G R X byte order on little-endian hosts.
inline constexpr std::uint32_t a8r8g8b8 = 0x20028888; ///< 32 bpp, B G R A byte order on little-endian hosts.
inline constexpr std::uint32_t x8b8g8r8 = 0x20030888; ///< 32 bpp, R G B X byte order on little-endian hosts.
inline constexpr std::uint32_t a8b8g8r8 = 0x20038888; ///< 32 bpp, R G B A byte order on little-endian hosts.
} // namespace pixman_format
/**
* @brief Move-only owner of a POSIX file descriptor.
*/
class fd_t {
public:
fd_t() = default;
/**
* @brief Take ownership of a file descriptor.
*
* @param fd File descriptor to own, or -1 for none.
*/
explicit fd_t(int fd);
/**
* @brief Transfer ownership from another descriptor owner.
*
* @param other Owner to take the descriptor from.
*/
fd_t(fd_t &&other) noexcept;
/**
* @brief Replace the owned descriptor with the one held by another owner.
*
* @param other Owner to take the descriptor from.
* @return This owner.
*/
fd_t &operator=(fd_t &&other) noexcept;
fd_t(const fd_t &) = delete;
fd_t &operator=(const fd_t &) = delete;
~fd_t();
/**
* @brief Access the owned descriptor without releasing it.
*
* @return The owned descriptor, or -1 when empty.
*/
[[nodiscard]] int get() const {
return fd;
}
/**
* @brief Release ownership of the descriptor.
*
* @return The descriptor, which the caller must close.
*/
int release();
private:
int fd {-1}; ///< Owned descriptor, or -1 when empty.
};
/**
* @brief Snapshot of one QEMU console.
*/
struct console_info_t {
std::uint32_t id {0}; ///< Console id, as used in `/org/qemu/Display1/Console_<id>`.
std::string label; ///< User-friendly console name, for example "VGA".
std::string type; ///< Console type, "Graphic" or "Text".
std::uint32_t head {0}; ///< Graphical device head number.
std::uint32_t width {0}; ///< Console width in pixels.
std::uint32_t height {0}; ///< Console height in pixels.
std::vector<std::string> interfaces; ///< Extra interfaces advertised by the console object.
/**
* @brief Report whether this console shows graphics.
*
* @return True when the console type is "Graphic".
*/
[[nodiscard]] bool is_graphic() const {
return type == "Graphic";
}
};
/**
* @brief Snapshot of the VM exported by QEMU.
*/
struct vm_info_t {
std::string name; ///< VM name (`-name`).
std::string uuid; ///< VM UUID.
std::vector<console_info_t> consoles; ///< Consoles listed by `ConsoleIDs`, in QEMU order.
};
/**
* @brief Receiver for the `org.qemu.Display1.Listener` calls QEMU makes.
* @details All methods are invoked on the session's listener thread. Implementations must return
* quickly and must not call back into the session synchronously from a different thread while
* holding locks that the caller of the session may also take.
*/
class display_listener_t {
public:
virtual ~display_listener_t() = default;
/**
* @brief Handle a full-frame scanout carried in the message.
*
* @param width Display width in pixels.
* @param height Display height in pixels.
* @param stride Bytes per row in `data`.
* @param format Pixman format code of `data`.
* @param data Pixel data, valid only for the duration of the call.
*/
virtual void scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) = 0;
/**
* @brief Handle a partial update carried in the message.
*
* @param x Left edge of the updated rectangle.
* @param y Top edge of the updated rectangle.
* @param width Width of the updated rectangle.
* @param height Height of the updated rectangle.
* @param stride Bytes per row in `data`.
* @param format Pixman format code of `data`.
* @param data Pixel data for the rectangle, valid only for the duration of the call.
*/
virtual void update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) = 0;
/**
* @brief Handle a scanout backed by shared memory.
*
* @param fd Shared memory descriptor; ownership moves to the listener.
* @param offset Offset of the first pixel in the mapping, in bytes.
* @param width Display width in pixels.
* @param height Display height in pixels.
* @param stride Bytes per row.
* @param format Pixman format code.
*/
virtual void scanout_map(fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) = 0;
/**
* @brief Handle damage on the current shared memory scanout.
*
* @param x Left edge of the damaged rectangle.
* @param y Top edge of the damaged rectangle.
* @param width Width of the damaged rectangle.
* @param height Height of the damaged rectangle.
*/
virtual void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) = 0;
/**
* @brief Handle the display being turned off.
*/
virtual void disable() = 0;
/**
* @brief Handle a cursor position or visibility change.
*
* @param x Cursor X position in pixels.
* @param y Cursor Y position in pixels.
* @param visible Whether the cursor is visible.
*/
virtual void mouse_set(std::int32_t x, std::int32_t y, bool visible) {
}
/**
* @brief Handle a new cursor shape.
*
* @param width Cursor width in pixels.
* @param height Cursor height in pixels.
* @param hot_x Hot-spot X position.
* @param hot_y Hot-spot Y position.
* @param data ARGB32 cursor pixels, valid only for the duration of the call.
*/
virtual void cursor_define(std::int32_t width, std::int32_t height, std::int32_t hot_x, std::int32_t hot_y, std::span<const std::uint8_t> data) {
}
/**
* @brief Handle the peer-to-peer connection to QEMU closing.
* @details Called at most once. No other method is called afterwards.
*/
virtual void disconnected() = 0;
};
/**
* @brief Registration of a display listener; destroying it unregisters the listener.
* @details After the destructor returns, no method of the listener is invoked again.
*/
class listener_registration_t {
public:
virtual ~listener_registration_t() = default;
};
/**
* @brief Connection to the D-Bus display of one QEMU VM.
* @details All public methods are thread-safe.
*/
class session_t {
public:
/**
* @brief Connect to a bus where QEMU owns `org.qemu` and discover its consoles.
*
* @param address D-Bus address of the bus, or empty for the session bus.
* @param timeout Timeout applied to each D-Bus call.
* @return Connected session, or nullptr when the bus or QEMU is unreachable.
*/
static std::shared_ptr<session_t> connect(const std::string &address, std::chrono::milliseconds timeout = 5s);
virtual ~session_t() = default;
/**
* @brief Snapshot the VM properties, including the current console sizes.
*
* @return VM information.
*/
[[nodiscard]] virtual vm_info_t vm() const = 0;
/**
* @brief Report whether QEMU is still reachable.
*
* @return False once the bus connection closed or `org.qemu` lost its owner.
*/
[[nodiscard]] virtual bool alive() const = 0;
/**
* @brief Register a display listener on a console.
*
* @param console_id Id of the console to listen to.
* @param listener Receiver for the display calls; kept alive by the registration.
* @return Registration handle, or nullptr when QEMU refused the listener.
*/
virtual std::unique_ptr<listener_registration_t> register_listener(std::uint32_t console_id, std::shared_ptr<display_listener_t> listener) = 0;
};
/**
* @brief Get the live session for an address, connecting when there is none.
* @details Capture, input, and audio share one session while any of them holds it. A session
* whose QEMU went away is replaced by a new connection attempt.
*
* @param address D-Bus address, or empty for the session bus.
* @return Live session, or nullptr when QEMU is unreachable.
*/
std::shared_ptr<session_t> shared_session(const std::string &address);
/**
* @brief Find a console by id or label.
*
* @param vm VM information to search.
* @param name Console id, label, or empty for the first graphical console.
* @return Matching console, or nothing when no console matches.
*/
std::optional<console_info_t> find_console(const vm_info_t &vm, std::string_view name);
/**
* @brief List the names Sunshine uses for the graphical consoles.
*
* @param vm VM information to list.
* @return Console ids as strings, for graphical consoles only.
*/
std::vector<std::string> graphic_console_names(const vm_info_t &vm);
} // namespace qemu
▸
src_assets/common/assets/web/config.html
+1
−0
@@ -304,6 +304,7 @@
"hevc_mode": 0,
"av1_mode": 0,
"capture": "",
"qemu_dbus_address": "",
"encoder": "",
},
},
▸
src_assets/common/assets/web/configs/tabs/Advanced.vue
+9
−0
@@ -76,6 +76,7 @@
<option value="x11">X11</option>
<option value="kwin">KWin Screencast</option>
<option value="portal">XDG Portal</option>
<option value="qemu">QEMU D-Bus Display</option>
</template>
<template #windows>
<option value="ddx">Desktop Duplication API</option>
@@ -84,6 +85,14 @@
</PlatformLayout>
</select>
<div class="form-text">{{ $t('config.capture_desc') }}</div>
</div>
<!-- QEMU D-Bus address -->
<div class="mb-3" v-if="platform === 'linux' && config.capture === 'qemu'">
<label for="qemu_dbus_address" class="form-label">{{ $t('config.qemu_dbus_address') }}</label>
<input type="text" class="form-control" id="qemu_dbus_address" placeholder="unix:path=/run/user/1000/qemu-vm0.sock"
v-model="config.qemu_dbus_address" />
<div class="form-text">{{ $t('config.qemu_dbus_address_desc') }}</div>
</div>
<!-- Encoder -->
▸
src_assets/common/assets/web/public/assets/locale/en.json
+2
−0
@@ -376,6 +376,8 @@
"port_udp": "UDP",
"port_warning": "Exposing the Web UI to the internet is a security risk! Proceed at your own risk!",
"port_web_ui": "Web UI",
"qemu_dbus_address": "QEMU D-Bus Address",
"qemu_dbus_address_desc": "D-Bus address of the bus the VM's display is on, as given to QEMU with -display dbus,addr=. Leave blank to use the session bus.",
"qp": "Quantization Parameter",
"qp_desc": "Some devices may not support Constant Bit Rate. For those devices, QP is used instead. Higher value means more compression, but less quality.",
"qsv_coder": "QuickSync Coder (H264)",
▸
tests/CMakeLists.txt
+2
−0
@@ -108,6 +108,8 @@
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS
${CMAKE_SOURCE_DIR}/tests/*.h
${CMAKE_SOURCE_DIR}/tests/*.cpp)
# end-to-end test tools are standalone programs with their own build
list(FILTER TEST_SOURCES EXCLUDE REGEX "^${CMAKE_SOURCE_DIR}/tests/e2e/")
if(SUNSHINE_ENABLE_TRAY AND (WIN32 OR APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux"))
set(SUNSHINE_TRAY_TEST_ICON_SOURCE_DIR
"${CMAKE_SOURCE_DIR}/src_assets/common/assets/web/public/images")
▸
tests/e2e/moonlight_client/CMakeLists.txt
+38
−0
@@ -1,0 +1,38 @@
# Headless Moonlight client for Sunshine end-to-end tests.
#
# Standalone project, not part of the Sunshine build: it needs the full moonlight-common-c library
# (Sunshine only compiles a few of its sources) and a decoder-enabled FFmpeg.
#
# cmake -S tests/e2e/moonlight_client -B cmake-build-e2e-client -G Ninja
# ninja -C cmake-build-e2e-client
cmake_minimum_required(VERSION 3.20)
project(sunshine_e2e_moonlight_client LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(SUNSHINE_SOURCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../..")
set(BUILD_SHARED_LIBS OFF)
add_subdirectory("${SUNSHINE_SOURCE_ROOT}/third-party/moonlight-common-c"
"${CMAKE_CURRENT_BINARY_DIR}/moonlight-common-c")
find_package(Threads REQUIRED)
find_package(CURL REQUIRED)
find_package(OpenSSL REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavcodec libavutil libswscale)
add_executable(moonlight_e2e_client
main.cpp
gamestream.h
gamestream.cpp)
target_include_directories(moonlight_e2e_client PRIVATE
"${SUNSHINE_SOURCE_ROOT}/third-party/moonlight-common-c/src")
target_link_libraries(moonlight_e2e_client PRIVATE
moonlight-common-c
CURL::libcurl
OpenSSL::SSL
OpenSSL::Crypto
PkgConfig::FFMPEG
Threads::Threads)
▸
tests/e2e/moonlight_client/gamestream.cpp
+458
−0
@@ -1,0 +1,458 @@
/**
* @file tests/e2e/moonlight_client/gamestream.cpp
* @brief Minimal GameStream HTTP client (serverinfo, pairing, applist, launch) for end-to-end tests.
* @details Implements the client side of the pairing handshake that Sunshine's `src/nvhttp.cpp`
* serves: AES-128-ECB with a key derived from SHA-256(salt || PIN), SHA-256 challenge hashes and
* RSA-SHA256 signatures.
*/
// class header include
#include "gamestream.h"
// standard includes
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <regex>
#include <sstream>
#include <stdexcept>
// lib includes
#include <curl/curl.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <openssl/x509.h>
namespace e2e {
namespace {
/**
* @brief Read a whole file.
*
* @param path File path.
* @return File contents.
*/
std::string read_file(const std::filesystem::path &path) {
std::ifstream in {path, std::ios::binary};
std::stringstream ss;
ss << in.rdbuf();
return ss.str();
}
/**
* @brief Write a whole file.
*
* @param path File path.
* @param data Contents.
*/
void write_file(const std::filesystem::path &path, const std::string &data) {
std::ofstream out {path, std::ios::binary | std::ios::trunc};
out << data;
}
/**
* @brief Decode hex text.
*
* @param hex Hex string.
* @return Bytes.
*/
std::vector<std::uint8_t> from_hex(const std::string &hex) {
std::vector<std::uint8_t> out;
for (std::size_t i = 0; i + 1 < hex.size(); i += 2) {
out.push_back((std::uint8_t) std::stoi(hex.substr(i, 2), nullptr, 16));
}
return out;
}
/**
* @brief SHA-256 of a buffer.
*
* @param data Input bytes.
* @return 32-byte digest.
*/
std::vector<std::uint8_t> sha256(const std::vector<std::uint8_t> &data) {
std::vector<std::uint8_t> out(32);
unsigned int len = 0;
EVP_Digest(data.data(), data.size(), out.data(), &len, EVP_sha256(), nullptr);
out.resize(len);
return out;
}
/**
* @brief AES-128-ECB without padding.
*
* @param key 16-byte key.
* @param data Input, a multiple of 16 bytes.
* @param encrypt Whether to encrypt or decrypt.
* @return Output bytes.
*/
std::vector<std::uint8_t> aes_ecb(const std::vector<std::uint8_t> &key, const std::vector<std::uint8_t> &data, bool encrypt) {
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> ctx {EVP_CIPHER_CTX_new(), &EVP_CIPHER_CTX_free};
EVP_CipherInit_ex(ctx.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr, encrypt ? 1 : 0);
EVP_CIPHER_CTX_set_padding(ctx.get(), 0);
std::vector<std::uint8_t> out(data.size() + 16);
int len = 0;
int total = 0;
EVP_CipherUpdate(ctx.get(), out.data(), &len, data.data(), (int) data.size());
total = len;
EVP_CipherFinal_ex(ctx.get(), out.data() + total, &len);
total += len;
out.resize(total);
return out;
}
/**
* @brief Sign with RSA-SHA256.
*
* @param key_pem Private key.
* @param data Data to sign.
* @return Signature.
*/
std::vector<std::uint8_t> sign_sha256(const std::string &key_pem, const std::vector<std::uint8_t> &data) {
std::unique_ptr<BIO, decltype(&BIO_free)> bio {BIO_new_mem_buf(key_pem.data(), (int) key_pem.size()), &BIO_free};
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey {PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), &EVP_PKEY_free};
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> ctx {EVP_MD_CTX_new(), &EVP_MD_CTX_free};
EVP_DigestSignInit(ctx.get(), nullptr, EVP_sha256(), nullptr, pkey.get());
std::size_t len = 0;
EVP_DigestSign(ctx.get(), nullptr, &len, data.data(), data.size());
std::vector<std::uint8_t> sig(len);
EVP_DigestSign(ctx.get(), sig.data(), &len, data.data(), data.size());
sig.resize(len);
return sig;
}
/**
* @brief Get the signature bytes embedded in a PEM certificate.
*
* @param cert_pem Certificate.
* @return Signature bytes.
*/
std::vector<std::uint8_t> cert_signature(const std::string &cert_pem) {
std::unique_ptr<BIO, decltype(&BIO_free)> bio {BIO_new_mem_buf(cert_pem.data(), (int) cert_pem.size()), &BIO_free};
std::unique_ptr<X509, decltype(&X509_free)> x509 {PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), &X509_free};
if (!x509) {
return {};
}
const ASN1_BIT_STRING *sig = nullptr;
const X509_ALGOR *alg = nullptr;
X509_get0_signature(&sig, &alg, x509.get());
return {sig->data, sig->data + sig->length};
}
/**
* @brief Create a self-signed RSA-2048 certificate.
*
* @return Certificate and key PEM.
*/
std::pair<std::string, std::string> make_certificate() {
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey {EVP_RSA_gen(2048), &EVP_PKEY_free};
std::unique_ptr<X509, decltype(&X509_free)> x509 {X509_new(), &X509_free};
X509_set_version(x509.get(), 2);
ASN1_INTEGER_set(X509_get_serialNumber(x509.get()), 1);
X509_gmtime_adj(X509_getm_notBefore(x509.get()), 0);
X509_gmtime_adj(X509_getm_notAfter(x509.get()), 60L * 60 * 24 * 365 * 20);
X509_set_pubkey(x509.get(), pkey.get());
auto name = X509_get_subject_name(x509.get());
X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const unsigned char *) "Sunshine E2E Client", -1, -1, 0);
X509_set_issuer_name(x509.get(), name);
X509_sign(x509.get(), pkey.get(), EVP_sha256());
std::unique_ptr<BIO, decltype(&BIO_free)> cert_bio {BIO_new(BIO_s_mem()), &BIO_free};
PEM_write_bio_X509(cert_bio.get(), x509.get());
std::unique_ptr<BIO, decltype(&BIO_free)> key_bio {BIO_new(BIO_s_mem()), &BIO_free};
PEM_write_bio_PrivateKey(key_bio.get(), pkey.get(), nullptr, nullptr, 0, nullptr, nullptr);
char *data = nullptr;
auto cert_len = BIO_get_mem_data(cert_bio.get(), &data);
std::string cert {data, (std::size_t) cert_len};
auto key_len = BIO_get_mem_data(key_bio.get(), &data);
std::string key {data, (std::size_t) key_len};
return {cert, key};
}
/**
* @brief libcurl write callback appending to a string.
*
* @param ptr Data.
* @param size Element size.
* @param nmemb Element count.
* @param userdata Target string.
* @return Bytes consumed.
*/
std::size_t append_body(char *ptr, std::size_t size, std::size_t nmemb, void *userdata) {
((std::string *) userdata)->append(ptr, size * nmemb);
return size * nmemb;
}
/**
* @brief Load a PEM string into a curl blob option.
*
* @param curl Handle.
* @param option Blob option.
* @param pem PEM text.
*/
void set_blob(CURL *curl, CURLoption option, const std::string &pem) {
curl_blob blob {};
blob.data = (void *) pem.data();
blob.len = pem.size();
blob.flags = CURL_BLOB_COPY;
curl_easy_setopt(curl, option, &blob);
}
} // namespace
identity_t load_or_create_identity(const std::string &dir) {
std::filesystem::create_directories(dir);
const auto cert_path = std::filesystem::path {dir} / "client.pem";
const auto key_path = std::filesystem::path {dir} / "client.key";
const auto id_path = std::filesystem::path {dir} / "uniqueid";
identity_t identity;
if (std::filesystem::exists(cert_path) && std::filesystem::exists(key_path) && std::filesystem::exists(id_path)) {
identity.cert_pem = read_file(cert_path);
identity.key_pem = read_file(key_path);
identity.unique_id = read_file(id_path);
return identity;
}
std::tie(identity.cert_pem, identity.key_pem) = make_certificate();
identity.unique_id = to_hex(random_bytes(8));
write_file(cert_path, identity.cert_pem);
write_file(key_path, identity.key_pem);
write_file(id_path, identity.unique_id);
return identity;
}
std::string to_hex(const std::vector<std::uint8_t> &data) {
static constexpr char digits[] = "0123456789ABCDEF";
std::string out;
out.reserve(data.size() * 2);
for (auto b : data) {
out.push_back(digits[b >> 4]);
out.push_back(digits[b & 0xf]);
}
return out;
}
std::vector<std::uint8_t> random_bytes(std::size_t n) {
std::vector<std::uint8_t> out(n);
RAND_bytes(out.data(), (int) n);
return out;
}
std::optional<std::string> xml_tag(const std::string &xml, const std::string &tag) {
std::smatch match;
if (std::regex_search(xml, match, std::regex {"<" + tag + ">([\\s\\S]*?)</" + tag + ">"})) {
return match[1].str();
}
return std::nullopt;
}
int xml_status(const std::string &xml) {
std::smatch match;
if (std::regex_search(xml, match, std::regex {"status_code=\"(-?\\d+)\""})) {
return std::stoi(match[1].str());
}
return -1;
}
client_t::client_t(std::string host, int http_port, identity_t identity):
host {std::move(host)},
http {http_port},
id {std::move(identity)} {
}
std::optional<std::string> client_t::get(bool use_https, const std::string &path, const std::string &query, long timeout_s) {
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl {curl_easy_init(), &curl_easy_cleanup};
auto url = std::string {use_https ? "https://" : "http://"} + host + ":" + std::to_string(use_https ? https : http) + path + "?uniqueid=" + id.unique_id + "&uuid=" + to_hex(random_bytes(16)) + (query.empty() ? "" : "&" + query);
std::string body;
curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &append_body);
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, timeout_s);
curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L);
if (use_https) {
// Sunshine's certificate is self-signed; pairing pins it, so peer verification is skipped here.
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, 0L);
set_blob(curl.get(), CURLOPT_SSLCERT_BLOB, id.cert_pem);
curl_easy_setopt(curl.get(), CURLOPT_SSLCERTTYPE, "PEM");
set_blob(curl.get(), CURLOPT_SSLKEY_BLOB, id.key_pem);
curl_easy_setopt(curl.get(), CURLOPT_SSLKEYTYPE, "PEM");
}
auto rc = curl_easy_perform(curl.get());
if (rc != CURLE_OK) {
std::fprintf(stderr, "e2e: GET %s failed: %s\n", path.c_str(), curl_easy_strerror(rc));
return std::nullopt;
}
return body;
}
std::optional<server_info_t> client_t::server_info(bool use_https) {
auto body = get(use_https, "/serverinfo", "");
if (!body || xml_status(*body) != 200) {
return std::nullopt;
}
server_info_t info;
info.app_version = xml_tag(*body, "appversion").value_or("");
info.gfe_version = xml_tag(*body, "GfeVersion").value_or("");
info.https_port = std::stoi(xml_tag(*body, "HttpsPort").value_or("0"));
info.codec_mode_support = std::stoi(xml_tag(*body, "ServerCodecModeSupport").value_or("0"));
info.paired = xml_tag(*body, "PairStatus").value_or("0") == "1";
info.state = xml_tag(*body, "state").value_or("");
https = info.https_port;
return info;
}
bool client_t::pair(const std::string &pin, const std::string &device_name, const std::function<bool()> &approve) {
auto salt = random_bytes(16);
std::vector<std::uint8_t> salted {salt};
salted.insert(salted.end(), pin.begin(), pin.end());
auto key = sha256(salted);
key.resize(16);
// Phase 1: send our certificate; the request stays pending until the PIN is approved.
auto approval = std::async(std::launch::async, approve);
auto phase1 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&phrase=getservercert&salt=" + to_hex(salt) + "&clientcert=" + to_hex({id.cert_pem.begin(), id.cert_pem.end()}), 120);
if (!phase1 || xml_tag(*phase1, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: getservercert failed: %s\n", phase1 ? phase1->c_str() : "no response");
approval.wait();
return false;
}
auto server_cert_hex = xml_tag(*phase1, "plaincert").value_or("");
auto server_cert_bytes = from_hex(server_cert_hex);
std::string server_cert {server_cert_bytes.begin(), server_cert_bytes.end()};
// Phase 2: client challenge
auto client_challenge = random_bytes(16);
auto phase2 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&clientchallenge=" + to_hex(aes_ecb(key, client_challenge, true)));
if (!phase2 || xml_tag(*phase2, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: clientchallenge failed\n");
approval.wait();
return false;
}
auto challenge_response = aes_ecb(key, from_hex(xml_tag(*phase2, "challengeresponse").value_or("")), false);
if (challenge_response.size() < 48) {
std::fprintf(stderr, "e2e: short challenge response\n");
approval.wait();
return false;
}
std::vector<std::uint8_t> server_response_hash {challenge_response.begin(), challenge_response.begin() + 32};
std::vector<std::uint8_t> server_challenge {challenge_response.begin() + 32, challenge_response.begin() + 48};
// Phase 3: answer the server challenge
auto client_secret = random_bytes(16);
std::vector<std::uint8_t> to_hash {server_challenge};
auto client_signature = cert_signature(id.cert_pem);
to_hash.insert(to_hash.end(), client_signature.begin(), client_signature.end());
to_hash.insert(to_hash.end(), client_secret.begin(), client_secret.end());
auto phase3 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&serverchallengeresp=" + to_hex(aes_ecb(key, sha256(to_hash), true)));
if (!phase3 || xml_tag(*phase3, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: serverchallengeresp failed\n");
approval.wait();
return false;
}
auto pairing_secret = from_hex(xml_tag(*phase3, "pairingsecret").value_or(""));
if (pairing_secret.size() < 16) {
std::fprintf(stderr, "e2e: short pairing secret\n");
approval.wait();
return false;
}
// Detect a man in the middle: the server proves it knew the PIN and owns its certificate.
std::vector<std::uint8_t> server_secret {pairing_secret.begin(), pairing_secret.begin() + 16};
std::vector<std::uint8_t> expected {client_challenge};
auto server_signature = cert_signature(server_cert);
expected.insert(expected.end(), server_signature.begin(), server_signature.end());
expected.insert(expected.end(), server_secret.begin(), server_secret.end());
if (sha256(expected) != server_response_hash) {
std::fprintf(stderr, "e2e: server response hash mismatch (wrong PIN?)\n");
approval.wait();
return false;
}
// Phase 4: send our signed secret
std::vector<std::uint8_t> client_pairing_secret {client_secret};
auto secret_signature = sign_sha256(id.key_pem, client_secret);
client_pairing_secret.insert(client_pairing_secret.end(), secret_signature.begin(), secret_signature.end());
auto phase4 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&clientpairingsecret=" + to_hex(client_pairing_secret));
bool approved = approval.get();
if (!phase4 || xml_tag(*phase4, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: clientpairingsecret failed\n");
return false;
}
// Phase 5: confirm over HTTPS with the now-trusted client certificate
if (https == 0) {
server_info(false);
}
auto phase5 = get(true, "/pair", "devicename=" + device_name + "&updateState=1&phrase=pairchallenge");
return approved && phase5 && xml_tag(*phase5, "paired").value_or("0") == "1";
}
std::vector<app_t> client_t::app_list() {
std::vector<app_t> apps;
auto body = get(true, "/applist", "");
if (!body) {
return apps;
}
std::regex app_re {"<App>([\\s\\S]*?)</App>"};
for (std::sregex_iterator it {body->begin(), body->end(), app_re}, end; it != end; ++it) {
auto app_xml = (*it)[1].str();
app_t app;
app.title = xml_tag(app_xml, "AppTitle").value_or("");
app.id = std::stoi(xml_tag(app_xml, "ID").value_or("0"));
apps.push_back(app);
}
return apps;
}
std::optional<std::string> client_t::launch(int app_id, int width, int height, int fps, const std::vector<std::uint8_t> &ri_key, int ri_key_id, const std::string &extra_query) {
auto query = "appid=" + std::to_string(app_id) + "&mode=" + std::to_string(width) + "x" + std::to_string(height) + "x" + std::to_string(fps) + "&additionalStates=1&sops=0&rikey=" + to_hex(ri_key) + "&rikeyid=" + std::to_string(ri_key_id) + "&localAudioPlayMode=0&surroundAudioInfo=196610&remoteControllersBitmap=0&gcmap=0" + extra_query;
auto body = get(true, "/launch", query, 60);
if (!body || xml_status(*body) != 200) {
std::fprintf(stderr, "e2e: launch failed: %s\n", body ? body->c_str() : "no response");
return std::nullopt;
}
return xml_tag(*body, "sessionUrl0");
}
bool client_t::cancel() {
auto body = get(true, "/cancel", "");
return body && xml_status(*body) == 200;
}
std::optional<std::string> api_request(const std::string &method, const std::string &url, const std::string &user, const std::string &password, const std::string &body) {
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl {curl_easy_init(), &curl_easy_cleanup};
std::string response;
auto userpwd = user + ":" + password;
curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
curl_easy_setopt(curl.get(), CURLOPT_CUSTOMREQUEST, method.c_str());
curl_easy_setopt(curl.get(), CURLOPT_USERPWD, userpwd.c_str());
curl_easy_setopt(curl.get(), CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &append_body);
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, 60L);
curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L);
std::unique_ptr<curl_slist, decltype(&curl_slist_free_all)> headers {nullptr, &curl_slist_free_all};
if (!body.empty()) {
headers.reset(curl_slist_append(nullptr, "Content-Type: application/json"));
curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get());
curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, body.c_str());
}
auto rc = curl_easy_perform(curl.get());
if (rc != CURLE_OK) {
std::fprintf(stderr, "e2e: %s %s failed: %s\n", method.c_str(), url.c_str(), curl_easy_strerror(rc));
return std::nullopt;
}
return response;
}
} // namespace e2e
▸
tests/e2e/moonlight_client/gamestream.h
+184
−0
@@ -1,0 +1,184 @@
/**
* @file tests/e2e/moonlight_client/gamestream.h
* @brief Minimal GameStream HTTP client (serverinfo, pairing, applist, launch) for end-to-end tests.
*/
#pragma once
// standard includes
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <vector>
namespace e2e {
/**
* @brief Client identity used for pairing and HTTPS requests.
*/
struct identity_t {
std::string cert_pem; ///< Self-signed client certificate.
std::string key_pem; ///< Private key of the certificate.
std::string unique_id; ///< Client unique id sent with every request.
};
/**
* @brief Load an identity from a directory, creating it on first use.
*
* @param dir Directory holding `client.pem`, `client.key` and `uniqueid`.
* @return Identity.
*/
identity_t load_or_create_identity(const std::string &dir);
/**
* @brief Parsed `/serverinfo` fields needed to start a stream.
*/
struct server_info_t {
std::string app_version; ///< `appversion`.
std::string gfe_version; ///< `GfeVersion`.
int https_port {0}; ///< `HttpsPort`.
int codec_mode_support {0}; ///< `ServerCodecModeSupport`.
bool paired {false}; ///< `PairStatus`.
std::string state; ///< `state`.
};
/**
* @brief One application from `/applist`.
*/
struct app_t {
std::string title; ///< App title.
int id {0}; ///< App id.
};
/**
* @brief GameStream client bound to one host.
*/
class client_t {
public:
/**
* @brief Create a client.
*
* @param host Host name or address.
* @param http_port Plain HTTP port (Sunshine `port`).
* @param identity Client identity.
*/
client_t(std::string host, int http_port, identity_t identity);
/**
* @brief Fetch `/serverinfo` over HTTP, or HTTPS when paired.
*
* @param https Whether to use the HTTPS port with the client certificate.
* @return Server information, or nothing on failure.
*/
std::optional<server_info_t> server_info(bool https);
/**
* @brief Run the four-phase pairing handshake.
*
* @param pin Four-digit PIN.
* @param device_name Name shown in Sunshine's pending pairing list.
* @param approve Called on another thread once `getservercert` is pending; must submit the PIN.
* @return True when the host reports the client as paired.
*/
bool pair(const std::string &pin, const std::string &device_name, const std::function<bool()> &approve);
/**
* @brief List applications over HTTPS.
*
* @return Applications, empty on failure.
*/
std::vector<app_t> app_list();
/**
* @brief Launch an application over HTTPS.
*
* @param app_id App id.
* @param width Stream width.
* @param height Stream height.
* @param fps Stream frame rate.
* @param ri_key Remote input AES key (16 bytes).
* @param ri_key_id Remote input key id.
* @param extra_query Extra query parameters, starting with `&`.
* @return RTSP session URL, or nothing on failure.
*/
std::optional<std::string> launch(int app_id, int width, int height, int fps, const std::vector<std::uint8_t> &ri_key, int ri_key_id, const std::string &extra_query);
/**
* @brief Quit the running application over HTTPS.
*
* @return True on success.
*/
bool cancel();
/**
* @brief HTTPS port learned from serverinfo.
*
* @return Port number.
*/
[[nodiscard]] int https_port() const {
return https;
}
private:
/**
* @brief Perform a GET request.
*
* @param https Whether to use HTTPS with the client certificate.
* @param path Path starting with `/`.
* @param query Query string without the leading `?`; uniqueid and uuid are appended.
* @param timeout_s Request timeout in seconds.
* @return Response body, or nothing on transport failure.
*/
std::optional<std::string> get(bool https, const std::string &path, const std::string &query, long timeout_s = 10);
std::string host; ///< Host name or address.
int http; ///< HTTP port.
int https {0}; ///< HTTPS port.
identity_t id; ///< Client identity.
};
/**
* @brief Extract the text of the first `<tag>` element.
*
* @param xml XML document.
* @param tag Element name.
* @return Element text, or nothing.
*/
std::optional<std::string> xml_tag(const std::string &xml, const std::string &tag);
/**
* @brief Extract the `status_code` attribute of the root element.
*
* @param xml XML document.
* @return Status code, or -1.
*/
int xml_status(const std::string &xml);
/**
* @brief Encode bytes as uppercase hex.
*
* @param data Bytes.
* @return Hex string.
*/
std::string to_hex(const std::vector<std::uint8_t> &data);
/**
* @brief Generate random bytes.
*
* @param n Number of bytes.
* @return Random bytes.
*/
std::vector<std::uint8_t> random_bytes(std::size_t n);
/**
* @brief Perform an authenticated JSON request against Sunshine's web API.
*
* @param method HTTP method.
* @param url Full URL.
* @param user Web UI user name.
* @param password Web UI password.
* @param body JSON body, empty for none.
* @return Response body, or nothing on transport failure.
*/
std::optional<std::string> api_request(const std::string &method, const std::string &url, const std::string &user, const std::string &password, const std::string &body);
} // namespace e2e
▸
tests/e2e/moonlight_client/main.cpp
+523
−0
@@ -1,0 +1,523 @@
/**
* @file tests/e2e/moonlight_client/main.cpp
* @brief Headless Moonlight client for Sunshine end-to-end tests.
* @details Pairs with a Sunshine host (approving the PIN through Sunshine's web API), launches an
* app, streams through moonlight-common-c, decodes H.264 with FFmpeg and checks the decoded picture
* against expected quadrant colors. Exit code 0 means the stream matched.
*
* Usage:
* @code{.sh}
* moonlight_e2e_client --port 47989 --api-user u --api-pass p --state-dir DIR \
* --expect "255,0,0;0,255,0;0,0,255;255,255,255" --out last.ppm --summary summary.json
* @endcode
*/
// standard includes
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <map>
#include <mutex>
#include <optional>
#include <regex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
// lib includes
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>
#include <Limelight.h>
}
// local includes
#include "gamestream.h"
using namespace std::literals;
namespace {
/**
* @brief Command line options.
*/
struct options_t {
std::string host {"127.0.0.1"}; ///< Sunshine host.
int port {47989}; ///< Sunshine base port (HTTP).
std::string api_user; ///< Web UI user.
std::string api_pass; ///< Web UI password.
std::string state_dir {"e2e-client"}; ///< Directory for the client identity.
int width {1280}; ///< Stream width.
int height {800}; ///< Stream height.
int fps {30}; ///< Stream frame rate.
int bitrate {8000}; ///< Stream bitrate in kbps.
int min_frames {30}; ///< Decoded frames required before checking.
int timeout_s {90}; ///< Overall stream timeout.
std::string app {"Desktop"}; ///< App title to launch.
std::string expect; ///< Expected quadrant colors "r,g,b;r,g,b;r,g,b;r,g,b".
int tolerance {48}; ///< Allowed per-channel difference.
std::string out; ///< PPM path for the last decoded frame.
std::string summary; ///< JSON path for the run summary.
};
/**
* @brief State shared with moonlight-common-c's context-free callbacks.
*/
struct stream_state_t {
std::mutex mutex; ///< Guards the fields below.
AVCodecContext *codec {nullptr}; ///< H.264 decoder.
AVFrame *frame {nullptr}; ///< Decoded frame.
AVPacket *packet {nullptr}; ///< Packet reused for decode units.
SwsContext *sws {nullptr}; ///< Converter to RGB24.
std::vector<std::uint8_t> rgb; ///< Last decoded picture, RGB24.
int rgb_width {0}; ///< Width of `rgb`.
int rgb_height {0}; ///< Height of `rgb`.
int decoded {0}; ///< Decoded frame count.
std::vector<double> host_latency_ms; ///< Host processing latency per frame.
std::atomic<bool> terminated {false}; ///< Whether the connection ended.
int termination_error {0}; ///< Error from connectionTerminated.
std::atomic<bool> started {false}; ///< Whether the connection started.
};
stream_state_t state;
int dr_setup(int video_format, int width, int height, int redraw_rate, void *context, int dr_flags) {
auto codec = avcodec_find_decoder(AV_CODEC_ID_H264);
if (!codec) {
std::fprintf(stderr, "e2e: FFmpeg has no H.264 decoder\n");
return -1;
}
state.codec = avcodec_alloc_context3(codec);
state.codec->thread_count = 1;
if (avcodec_open2(state.codec, codec, nullptr) < 0) {
std::fprintf(stderr, "e2e: couldn't open the H.264 decoder\n");
return -1;
}
state.frame = av_frame_alloc();
state.packet = av_packet_alloc();
std::fprintf(stderr, "e2e: decoder set up for %dx%d@%d\n", width, height, redraw_rate);
return 0;
}
void dr_cleanup() {
std::lock_guard lock {state.mutex};
sws_freeContext(state.sws);
state.sws = nullptr;
av_packet_free(&state.packet);
av_frame_free(&state.frame);
avcodec_free_context(&state.codec);
}
int dr_submit(PDECODE_UNIT unit) {
std::vector<std::uint8_t> data;
data.reserve(unit->fullLength + AV_INPUT_BUFFER_PADDING_SIZE);
for (auto entry = unit->bufferList; entry; entry = entry->next) {
data.insert(data.end(), entry->data, entry->data + entry->length);
}
data.resize(data.size() + AV_INPUT_BUFFER_PADDING_SIZE, 0);
std::lock_guard lock {state.mutex};
if (unit->frameHostProcessingLatency) {
state.host_latency_ms.push_back(unit->frameHostProcessingLatency / 10.0);
}
state.packet->data = data.data();
state.packet->size = unit->fullLength;
if (avcodec_send_packet(state.codec, state.packet) < 0) {
return DR_NEED_IDR;
}
while (avcodec_receive_frame(state.codec, state.frame) == 0) {
auto w = state.frame->width;
auto h = state.frame->height;
state.sws = sws_getCachedContext(state.sws, w, h, (AVPixelFormat) state.frame->format, w, h, AV_PIX_FMT_RGB24, SWS_POINT, nullptr, nullptr, nullptr);
state.rgb.resize((std::size_t) w * h * 3);
std::uint8_t *dst[1] = {state.rgb.data()};
int dst_stride[1] = {w * 3};
sws_scale(state.sws, (const std::uint8_t *const *) state.frame->data, state.frame->linesize, 0, h, dst, dst_stride);
state.rgb_width = w;
state.rgb_height = h;
state.decoded += 1;
}
return DR_OK;
}
void cl_stage_failed(int stage, int error_code) {
std::fprintf(stderr, "e2e: stage %s failed: %d\n", LiGetStageName(stage), error_code);
}
void cl_connection_started() {
std::fprintf(stderr, "e2e: connection started\n");
state.started = true;
}
void cl_connection_terminated(int error_code) {
std::fprintf(stderr, "e2e: connection terminated: %d\n", error_code);
state.termination_error = error_code;
state.terminated = true;
}
void cl_log(const char *format, ...) {
va_list args;
va_start(args, format);
std::fprintf(stderr, "moonlight: ");
std::vfprintf(stderr, format, args);
va_end(args);
}
/**
* @brief Parse "r,g,b;r,g,b;r,g,b;r,g,b".
*
* @param text Expectation text.
* @return Four RGB triples, or nothing on a syntax error.
*/
std::optional<std::vector<std::array<int, 3>>> parse_expect(const std::string &text) {
std::vector<std::array<int, 3>> colors;
std::stringstream ss {text};
std::string item;
while (std::getline(ss, item, ';')) {
std::array<int, 3> rgb {};
if (std::sscanf(item.c_str(), "%d,%d,%d", &rgb[0], &rgb[1], &rgb[2]) != 3) {
return std::nullopt;
}
colors.push_back(rgb);
}
if (colors.size() != 4) {
return std::nullopt;
}
return colors;
}
/**
* @brief Mean color of a square around a point.
*
* @param rgb Picture.
* @param w Width.
* @param h Height.
* @param cx Center X.
* @param cy Center Y.
* @return Mean RGB.
*/
std::array<int, 3> mean_color(const std::vector<std::uint8_t> &rgb, int w, int h, int cx, int cy) {
const int r = std::max(2, std::min(w, h) / 40);
long sum[3] = {0, 0, 0};
long n = 0;
for (int y = std::max(0, cy - r); y < std::min(h, cy + r); ++y) {
for (int x = std::max(0, cx - r); x < std::min(w, cx + r); ++x) {
auto p = &rgb[((std::size_t) y * w + x) * 3];
sum[0] += p[0];
sum[1] += p[1];
sum[2] += p[2];
n += 1;
}
}
if (n == 0) {
return {0, 0, 0};
}
return {(int) (sum[0] / n), (int) (sum[1] / n), (int) (sum[2] / n)};
}
/**
* @brief Sample the quadrant centers of a picture.
*
* @param rgb Picture.
* @param w Width.
* @param h Height.
* @return Top-left, top-right, bottom-left, bottom-right mean colors.
*/
std::vector<std::array<int, 3>> quadrant_colors(const std::vector<std::uint8_t> &rgb, int w, int h) {
return {
mean_color(rgb, w, h, w / 4, h / 4),
mean_color(rgb, w, h, 3 * w / 4, h / 4),
mean_color(rgb, w, h, w / 4, 3 * h / 4),
mean_color(rgb, w, h, 3 * w / 4, 3 * h / 4),
};
}
/**
* @brief Compare sampled colors to the expectation.
*
* @param got Sampled colors.
* @param want Expected colors.
* @param tolerance Allowed per-channel difference.
* @return True when every channel is within tolerance.
*/
bool colors_match(const std::vector<std::array<int, 3>> &got, const std::vector<std::array<int, 3>> &want, int tolerance) {
for (std::size_t q = 0; q < want.size(); ++q) {
for (int c = 0; c < 3; ++c) {
if (std::abs(got[q][c] - want[q][c]) > tolerance) {
return false;
}
}
}
return true;
}
/**
* @brief Percentile of a sample set.
*
* @param values Samples.
* @param p Percentile in [0, 100].
* @return Percentile value, or 0 for no samples.
*/
double percentile(std::vector<double> values, double p) {
if (values.empty()) {
return 0;
}
std::ranges::sort(values);
auto index = (std::size_t) std::lround((p / 100.0) * (values.size() - 1));
return values[index];
}
/**
* @brief Approve the pending pairing through Sunshine's web API.
*
* @param opts Options.
* @param device_name Name the client paired with.
* @param pin PIN to submit.
* @return True when Sunshine accepted the PIN.
*/
bool approve_pin(const options_t &opts, const std::string &device_name, const std::string &pin) {
const auto api = "https://" + opts.host + ":" + std::to_string(opts.port + 1) + "/api/pin";
const auto deadline = std::chrono::steady_clock::now() + 60s;
while (std::chrono::steady_clock::now() < deadline) {
auto list = e2e::api_request("GET", api, opts.api_user, opts.api_pass, "");
std::smatch match;
std::regex entry {"\\{[^}]*\"id\"\\s*:\\s*\"([0-9a-f]{32})\"[^}]*\"name\"\\s*:\\s*\"" + device_name + "\"[^}]*\\}"};
std::regex entry_name_first {"\\{[^}]*\"name\"\\s*:\\s*\"" + device_name + "\"[^}]*\"id\"\\s*:\\s*\"([0-9a-f]{32})\"[^}]*\\}"};
if (list && (std::regex_search(*list, match, entry) || std::regex_search(*list, match, entry_name_first))) {
const auto body = "{\"pairing_id\":\"" + match[1].str() + "\",\"pin\":\"" + pin + "\",\"name\":\"" + device_name + "\"}";
auto result = e2e::api_request("POST", api, opts.api_user, opts.api_pass, body);
std::fprintf(stderr, "e2e: PIN approval response: %s\n", result ? result->c_str() : "none");
return result && result->find("\"status\":true") != std::string::npos;
}
std::this_thread::sleep_for(200ms);
}
std::fprintf(stderr, "e2e: pairing request never appeared in /api/pin\n");
return false;
}
/**
* @brief Write the last frame as a binary PPM.
*
* @param path Output path.
*/
void write_ppm(const std::string &path) {
std::lock_guard lock {state.mutex};
if (path.empty() || state.rgb.empty()) {
return;
}
std::ofstream out {path, std::ios::binary};
out << "P6\n"
<< state.rgb_width << ' ' << state.rgb_height << "\n255\n";
out.write((const char *) state.rgb.data(), (std::streamsize) state.rgb.size());
}
/**
* @brief Parse the command line.
*
* @param argc Argument count.
* @param argv Arguments.
* @return Options, or nothing on a usage error.
*/
std::optional<options_t> parse_args(int argc, char **argv) {
options_t opts;
std::map<std::string, std::string *> strings {
{"--host", &opts.host},
{"--api-user", &opts.api_user},
{"--api-pass", &opts.api_pass},
{"--state-dir", &opts.state_dir},
{"--app", &opts.app},
{"--expect", &opts.expect},
{"--out", &opts.out},
{"--summary", &opts.summary},
};
std::map<std::string, int *> ints {
{"--port", &opts.port},
{"--width", &opts.width},
{"--height", &opts.height},
{"--fps", &opts.fps},
{"--bitrate", &opts.bitrate},
{"--frames", &opts.min_frames},
{"--timeout", &opts.timeout_s},
{"--tolerance", &opts.tolerance},
};
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (i + 1 >= argc) {
std::fprintf(stderr, "missing value for %s\n", arg.c_str());
return std::nullopt;
}
if (auto s = strings.find(arg); s != strings.end()) {
*s->second = argv[++i];
} else if (auto n = ints.find(arg); n != ints.end()) {
*n->second = std::stoi(argv[++i]);
} else {
std::fprintf(stderr, "unknown option %s\n", arg.c_str());
return std::nullopt;
}
}
return opts;
}
} // namespace
int main(int argc, char **argv) {
auto parsed = parse_args(argc, argv);
if (!parsed) {
return 2;
}
const auto opts = *parsed;
std::optional<std::vector<std::array<int, 3>>> expected;
if (!opts.expect.empty()) {
expected = parse_expect(opts.expect);
if (!expected) {
std::fprintf(stderr, "e2e: bad --expect value\n");
return 2;
}
}
auto identity = e2e::load_or_create_identity(opts.state_dir);
e2e::client_t client {opts.host, opts.port, identity};
auto info = client.server_info(false);
if (!info) {
std::fprintf(stderr, "e2e: serverinfo failed\n");
return 1;
}
std::fprintf(stderr, "e2e: host appversion=%s state=%s paired=%d\n", info->app_version.c_str(), info->state.c_str(), info->paired);
if (!client.server_info(true) || !client.server_info(true)->paired) {
const auto device_name = "e2e-" + identity.unique_id.substr(0, 8);
const auto pin_bytes = e2e::random_bytes(2);
char pin[5];
std::snprintf(pin, sizeof(pin), "%04d", ((pin_bytes[0] << 8) | pin_bytes[1]) % 10000);
std::fprintf(stderr, "e2e: pairing as %s\n", device_name.c_str());
if (!client.pair(pin, device_name, [&]() {
return approve_pin(opts, device_name, pin);
})) {
std::fprintf(stderr, "e2e: pairing failed\n");
return 1;
}
std::fprintf(stderr, "e2e: paired\n");
info = client.server_info(true);
if (!info || !info->paired) {
std::fprintf(stderr, "e2e: host doesn't report the client as paired\n");
return 1;
}
}
auto apps = client.app_list();
auto app = std::ranges::find_if(apps, [&](const e2e::app_t &a) {
return a.title == opts.app;
});
if (app == apps.end()) {
std::fprintf(stderr, "e2e: app [%s] not found (%zu apps)\n", opts.app.c_str(), apps.size());
return 1;
}
auto ri_key = e2e::random_bytes(16);
const int ri_key_id = 0x1234;
auto session_url = client.launch(app->id, opts.width, opts.height, opts.fps, ri_key, ri_key_id, LiGetLaunchUrlQueryParameters());
if (!session_url) {
return 1;
}
std::fprintf(stderr, "e2e: launched app %d, session %s\n", app->id, session_url->c_str());
SERVER_INFORMATION server;
LiInitializeServerInformation(&server);
server.address = opts.host.c_str();
server.serverInfoAppVersion = info->app_version.c_str();
server.serverInfoGfeVersion = info->gfe_version.c_str();
server.rtspSessionUrl = session_url->c_str();
server.serverCodecModeSupport = info->codec_mode_support;
STREAM_CONFIGURATION config;
LiInitializeStreamConfiguration(&config);
config.width = opts.width;
config.height = opts.height;
config.fps = opts.fps;
config.bitrate = opts.bitrate;
config.packetSize = 1392;
config.streamingRemotely = STREAM_CFG_LOCAL;
config.audioConfiguration = AUDIO_CONFIGURATION_STEREO;
config.supportedVideoFormats = VIDEO_FORMAT_H264;
config.encryptionFlags = ENCFLG_AUDIO;
std::memcpy(config.remoteInputAesKey, ri_key.data(), 16);
std::memset(config.remoteInputAesIv, 0, 16);
config.remoteInputAesIv[0] = (char) ((ri_key_id >> 24) & 0xff);
config.remoteInputAesIv[1] = (char) ((ri_key_id >> 16) & 0xff);
config.remoteInputAesIv[2] = (char) ((ri_key_id >> 8) & 0xff);
config.remoteInputAesIv[3] = (char) (ri_key_id & 0xff);
CONNECTION_LISTENER_CALLBACKS listener;
LiInitializeConnectionCallbacks(&listener);
listener.stageFailed = cl_stage_failed;
listener.connectionStarted = cl_connection_started;
listener.connectionTerminated = cl_connection_terminated;
listener.logMessage = cl_log;
DECODER_RENDERER_CALLBACKS decoder;
LiInitializeVideoCallbacks(&decoder);
decoder.setup = dr_setup;
decoder.cleanup = dr_cleanup;
decoder.submitDecodeUnit = dr_submit;
const auto stream_start = std::chrono::steady_clock::now();
if (LiStartConnection(&server, &config, &listener, &decoder, nullptr, nullptr, 0, nullptr, 0) != 0) {
std::fprintf(stderr, "e2e: LiStartConnection failed\n");
client.cancel();
return 1;
}
bool matched = false;
std::vector<std::array<int, 3>> last_colors;
const auto deadline = stream_start + std::chrono::seconds(opts.timeout_s);
while (std::chrono::steady_clock::now() < deadline && !state.terminated) {
std::this_thread::sleep_for(100ms);
std::lock_guard lock {state.mutex};
if (state.decoded < opts.min_frames || state.rgb.empty()) {
continue;
}
if (!expected) {
matched = true;
break;
}
last_colors = quadrant_colors(state.rgb, state.rgb_width, state.rgb_height);
if (colors_match(last_colors, *expected, opts.tolerance)) {
matched = true;
break;
}
}
const auto elapsed = std::chrono::duration<double>(std::chrono::steady_clock::now() - stream_start).count();
write_ppm(opts.out);
LiStopConnection();
client.cancel();
std::vector<double> latencies;
int decoded = 0;
{
std::lock_guard lock {state.mutex};
latencies = state.host_latency_ms;
decoded = state.decoded;
}
std::ostringstream summary;
summary << "{\"matched\":" << (matched ? "true" : "false") << ",\"decoded_frames\":" << decoded << ",\"seconds\":" << elapsed << ",\"host_latency_ms\":{\"samples\":" << latencies.size() << ",\"p50\":" << percentile(latencies, 50) << ",\"p95\":" << percentile(latencies, 95) << "},\"quadrants\":[";
for (std::size_t i = 0; i < last_colors.size(); ++i) {
summary << (i ? "," : "") << "[" << last_colors[i][0] << "," << last_colors[i][1] << "," << last_colors[i][2] << "]";
}
summary << "],\"terminated\":" << (state.terminated ? "true" : "false") << "}";
std::printf("%s\n", summary.str().c_str());
if (!opts.summary.empty()) {
std::ofstream {opts.summary} << summary.str() << "\n";
}
return matched ? 0 : 1;
}
▸
tests/e2e/qemu/e2e_stream.sh
+153
−0
@@ -1,0 +1,153 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-E2E-001, REQ-NFR-001]
# End-to-end test (REQ-E2E-001): an unmodified Moonlight client streams a QEMU VM that has no guest agent.
#
# 1. start a private dbus-daemon and a QEMU guest that draws a known pattern (run_vm.sh)
# 2. start Sunshine with capture = qemu on a unique port (software encoding unless E2E_ENCODER is set)
# 3. pair a headless Moonlight client (PIN approved through Sunshine's web API), launch Desktop
# 4. decode the stream and check the quadrant colors of the guest pattern
# 5. tear everything down, also on failure
#
# Environment:
# SUNSHINE_BIN Sunshine binary (default: <repo>/cmake-build-debug/sunshine)
# CLIENT_BIN E2E client (default: <repo>/cmake-build-e2e-client/moonlight_e2e_client)
# QEMU QEMU binary (default: qemu-system-x86_64)
# E2E_CACHE cache for the guest image (default: ~/.cache/sunshine-qemu/e2e)
# E2E_ARTIFACTS where logs and the last frame go (default: <work-dir>/artifacts, kept on failure)
# E2E_PORT Sunshine base port (default: random in 21000-39900, step 100)
# E2E_TIMEOUT stream timeout in seconds (default: 90; raise it for TCG)
# E2E_WIDTH/E2E_HEIGHT/E2E_FPS stream mode (default: 1280x800 at 30 fps)
# E2E_FRAMES decoded frames to receive before checking (default: 30; raise for latency runs)
# E2E_ENCODER Sunshine encoder: software, nvenc, vaapi, vulkan (default: software)
# E2E_KEEP set to 1 to keep the work directory
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${script_dir}/../../.." && pwd)"
sunshine_bin="${SUNSHINE_BIN:-${repo}/cmake-build-debug/sunshine}"
client_bin="${CLIENT_BIN:-${repo}/cmake-build-e2e-client/moonlight_e2e_client}"
cache="${E2E_CACHE:-${HOME}/.cache/sunshine-qemu/e2e}"
port="${E2E_PORT:-$((21000 + (RANDOM % 190) * 100))}"
timeout_s="${E2E_TIMEOUT:-90}"
width="${E2E_WIDTH:-1280}"
height="${E2E_HEIGHT:-800}"
fps="${E2E_FPS:-30}"
frames="${E2E_FRAMES:-30}"
encoder="${E2E_ENCODER:-software}"
for bin in "${sunshine_bin}" "${client_bin}"; do
if [[ ! -x "${bin}" ]]; then
echo "missing ${bin}; build it first" >&2
exit 2
fi
done
work="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-e2e.XXXXXX")"
artifacts="${E2E_ARTIFACTS:-${work}/artifacts}"
mkdir -p "${artifacts}"
result=1
cleanup() {
set +e
for pidfile in "${work}/sunshine.pid" "${work}/vm/qemu.pid" "${work}/vm/dbus.pid"; do
if [[ -f "${pidfile}" ]]; then
pid="$(cat "${pidfile}")"
kill "${pid}" 2>/dev/null
for _ in $(seq 1 50); do
kill -0 "${pid}" 2>/dev/null || break
sleep 0.1
done
kill -9 "${pid}" 2>/dev/null
fi
done
cp -f "${work}/sunshine.log" "${work}/sunshine.stdout" "${work}/client.log" "${work}/vm/qemu.log" "${work}/vm/dbus.log" "${artifacts}/" 2>/dev/null
if [[ ${result} == 0 && "${E2E_KEEP:-0}" != 1 && -z "${E2E_ARTIFACTS:-}" ]]; then
rm -rf "${work}"
else
echo "e2e: work directory kept at ${work} (artifacts: ${artifacts})" >&2
fi
}
trap cleanup EXIT
# 1. guest
image="${cache}/pattern-$(sha256sum "${script_dir}/guest/pattern.S" | cut -c1-16).img"
if [[ ! -f "${image}" ]]; then
"${script_dir}/guest/build_guest.sh" "${image}" > /dev/null
fi
bus_address="$("${script_dir}/run_vm.sh" "${work}/vm" "${image}")"
echo "e2e: VM up on ${bus_address}" >&2
# 2. Sunshine
cat > "${work}/apps.json" <<'EOF'
{
"env": {},
"apps": [
{"name": "Desktop", "image-path": "desktop.png"}
]
}
EOF
cat > "${work}/sunshine.conf" <<EOF
capture = qemu
qemu_dbus_address = ${bus_address}
encoder = ${encoder}
port = ${port}
sunshine_name = sunshine-e2e-${port}
file_state = ${work}/sunshine_state.json
credentials_file = ${work}/sunshine_credentials.json
pkey = ${work}/sunshine.key
cert = ${work}/sunshine.crt
file_apps = ${work}/apps.json
log_path = ${work}/sunshine.log
min_log_level = debug
upnp = disabled
system_tray = disabled
origin_web_ui_allowed = pc
notify_pre_releases = disabled
EOF
api_user="e2e"
api_pass="$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
"${sunshine_bin}" "${work}/sunshine.conf" --creds "${api_user}" "${api_pass}" > "${work}/creds.log" 2>&1
"${sunshine_bin}" "${work}/sunshine.conf" > "${work}/sunshine.stdout" 2>&1 &
echo $! > "${work}/sunshine.pid"
for _ in $(seq 1 300); do
if ! kill -0 "$(cat "${work}/sunshine.pid")" 2>/dev/null; then
echo "e2e: Sunshine exited early" >&2
tail -50 "${work}/sunshine.stdout" >&2
exit 1
fi
if curl -s --max-time 1 "http://127.0.0.1:${port}/serverinfo" | grep -q "status_code=\"200\""; then
break
fi
sleep 0.1
done
echo "e2e: Sunshine up on port ${port}" >&2
# 3-4. pair, stream, check the pattern (red, green, blue, white quadrants)
set +e
"${client_bin}" \
--port "${port}" \
--api-user "${api_user}" \
--api-pass "${api_pass}" \
--state-dir "${work}/client" \
--width "${width}" --height "${height}" --fps "${fps}" \
--frames "${frames}" \
--timeout "${timeout_s}" \
--expect "255,0,0;0,255,0;0,0,255;255,255,255" \
--tolerance 48 \
--out "${artifacts}/last_frame.ppm" \
--summary "${artifacts}/summary.json" \
2> "${work}/client.log"
result=$?
set -e
if [[ ${result} == 0 ]]; then
echo "e2e: PASS $(cat "${artifacts}/summary.json")"
else
echo "e2e: FAIL (client exit ${result})" >&2
tail -40 "${work}/client.log" >&2
fi
exit "${result}"
▸
tests/e2e/qemu/guest/build_guest.sh
+28
−0
@@ -1,0 +1,28 @@
#!/usr/bin/env bash
# Build the test-pattern guest disk image.
#
# Usage: build_guest.sh <output-image>
#
# Produces a 1 MiB raw disk whose first sector is the boot sector from pattern.S.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
output="${1:?usage: build_guest.sh <output-image>}"
work="$(mktemp -d)"
trap 'rm -rf "${work}"' EXIT
as --32 -o "${work}/pattern.o" "${script_dir}/pattern.S"
ld -m elf_i386 -Ttext 0x7c00 -e _start --oformat binary -o "${work}/pattern.bin" "${work}/pattern.o"
size="$(stat -c %s "${work}/pattern.bin")"
if [[ "${size}" != 512 ]]; then
echo "boot sector is ${size} bytes, expected 512" >&2
exit 1
fi
mkdir -p "$(dirname "${output}")"
truncate -s 1M "${work}/disk.img"
dd if="${work}/pattern.bin" of="${work}/disk.img" conv=notrunc status=none
mv "${work}/disk.img" "${output}"
echo "${output}"
▸
tests/e2e/qemu/guest/pattern.S
+98
−0
@@ -1,0 +1,98 @@
/*
* Boot sector that draws a known test pattern, with no guest agent or OS.
*
* VGA mode 13h (320x200, 256 colors) with an explicit palette:
* top-left red, top-right green, bottom-left blue, bottom-right white.
* A 16x16 square in the center alternates between black and yellow on every BIOS timer tick
* (about 18 times a second), so QEMU keeps sending damage for it.
*
* Build: see build_guest.sh (GNU as + ld, no other tools).
*/
.code16
.globl _start
_start:
cli
xor %ax, %ax
mov %ax, %ds
mov %ax, %ss
mov $0x7c00, %sp
cld
sti
/* VGA mode 13h */
mov $0x0013, %ax
int $0x10
/* palette entries 1..6, 6-bit DAC values */
mov $0x3c8, %dx
mov $1, %al
outb %al, %dx
inc %dx
mov $palette, %si
mov $18, %cx
set_palette:
lodsb
outb %al, %dx
loop set_palette
/* quadrants: color = 1 + (x >= 160) + 2 * (y >= 100) */
mov $0xa000, %ax
mov %ax, %es
xor %di, %di
xor %bx, %bx
fill_row:
xor %cx, %cx
fill_col:
mov $1, %al
cmp $160, %cx
jb 1f
inc %al
1:
cmp $100, %bx
jb 2f
add $2, %al
2:
stosb
inc %cx
cmp $320, %cx
jb fill_col
inc %bx
cmp $200, %bx
jb fill_row
animate:
/* BIOS tick count in CX:DX; odd ticks draw yellow (6), even ticks black (5) */
xor %ah, %ah
int $0x1a
mov %dl, %al
and $1, %al
add $5, %al
mov $92, %bx
square_row:
/* di = y * 320 + 152 */
mov %bx, %di
shl $6, %di
mov %bx, %dx
shl $8, %dx
add %dx, %di
add $152, %di
mov $16, %cx
rep stosb
inc %bx
cmp $108, %bx
jb square_row
hlt
jmp animate
palette:
.byte 63, 0, 0 /* 1: red */
.byte 0, 63, 0 /* 2: green */
.byte 0, 0, 63 /* 3: blue */
.byte 63, 63, 63 /* 4: white */
.byte 0, 0, 0 /* 5: black */
.byte 63, 63, 0 /* 6: yellow */
.org 510
.word 0xaa55
▸
tests/e2e/qemu/run_vm.sh
+85
−0
@@ -1,0 +1,85 @@
#!/usr/bin/env bash
# Start a private dbus-daemon and a QEMU guest that exports its display and audio on it.
#
# Usage: run_vm.sh <work-dir> <disk-image> [extra qemu args...]
#
# Environment:
# QEMU QEMU binary (default: qemu-system-x86_64 from PATH)
# VM_NAME VM name (default: e2e-guest)
# VM_ACCEL "kvm" or "tcg" (default: kvm when /dev/kvm is usable, else tcg)
# VM_START_WAIT seconds to wait for org.qemu on the bus (default: 30)
#
# Writes into <work-dir>:
# bus.sock the private bus socket; bus address is unix:path=<work-dir>/bus.sock
# dbus.pid dbus-daemon pid
# qemu.pid QEMU pid
# qmp.sock QMP socket
# qemu.log QEMU stdout/stderr
# dbus.log dbus-daemon stdout/stderr
set -euo pipefail
work="${1:?usage: run_vm.sh <work-dir> <disk-image> [qemu args...]}"
image="${2:?usage: run_vm.sh <work-dir> <disk-image> [qemu args...]}"
shift 2
qemu="${QEMU:-qemu-system-x86_64}"
name="${VM_NAME:-e2e-guest}"
wait_s="${VM_START_WAIT:-30}"
if [[ -z "${VM_ACCEL:-}" ]]; then
if [[ -r /dev/kvm && -w /dev/kvm ]]; then
VM_ACCEL=kvm
else
VM_ACCEL=tcg
fi
fi
mkdir -p "${work}"
bus_socket="${work}/bus.sock"
bus_address="unix:path=${bus_socket}"
rm -f "${bus_socket}" "${work}/qmp.sock"
dbus-daemon --session --nofork --nopidfile --address="${bus_address}" > "${work}/dbus.log" 2>&1 &
echo $! > "${work}/dbus.pid"
for _ in $(seq 1 100); do
[[ -S "${bus_socket}" ]] && break
sleep 0.05
done
if [[ ! -S "${bus_socket}" ]]; then
echo "dbus-daemon didn't create ${bus_socket}" >&2
exit 1
fi
"${qemu}" \
-name "${name}" \
-accel "${VM_ACCEL}" \
-m 128 \
-nodefaults \
-device virtio-vga \
-display dbus,addr="${bus_address}" \
-audiodev dbus,id=snd0 \
-device intel-hda -device hda-output,audiodev=snd0 \
-device virtio-tablet-pci \
-device virtio-keyboard-pci \
-drive file="${image}",format=raw,if=ide,snapshot=on \
-qmp unix:"${work}/qmp.sock",server=on,wait=off \
"$@" > "${work}/qemu.log" 2>&1 &
echo $! > "${work}/qemu.pid"
for _ in $(seq 1 $((wait_s * 10))); do
if ! kill -0 "$(cat "${work}/qemu.pid")" 2>/dev/null; then
echo "QEMU exited early; see ${work}/qemu.log" >&2
cat "${work}/qemu.log" >&2
exit 1
fi
if gdbus call --address "${bus_address}" --dest org.freedesktop.DBus --object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.NameHasOwner org.qemu 2>/dev/null | grep -q true; then
echo "${bus_address}"
exit 0
fi
sleep 0.1
done
echo "QEMU didn't own org.qemu within ${wait_s}s" >&2
exit 1
▸
tests/e2e/README.md
+62
−0
@@ -1,0 +1,62 @@
# End-to-end tests
These tests run the real pipeline: a QEMU VM with no guest agent, Sunshine with `capture = qemu`,
and a headless Moonlight client that pairs, streams, decodes, and checks the picture. They are not
part of `test_sunshine`; `tests/CMakeLists.txt` excludes this directory.
## Layout
| Path | What it is |
|------------------------------|-------------------------------------------------------------------------------------------|
| `qemu/guest/pattern.S` | 512-byte boot sector: VGA mode 13h with red, green, blue, white quadrants and a blinking square |
| `qemu/guest/build_guest.sh` | Builds the guest disk image with GNU `as`/`ld` (no binaries are committed) |
| `qemu/run_vm.sh` | Starts a private `dbus-daemon` and QEMU with `-display dbus,addr=...` and `-audiodev dbus` |
| `qemu/e2e_stream.sh` | The end-to-end test (REQ-E2E-001), also used for latency numbers (REQ-NFR-001) |
| `moonlight_client/` | Headless client: GameStream pairing/launch plus moonlight-common-c and FFmpeg decoding |
## Requirements
- A Sunshine build with `SUNSHINE_ENABLE_QEMU=ON`.
- QEMU with D-Bus display (`qemu-system-x86_64 -display help | grep dbus`). Both paths are tested:
QEMU 8.2 (Ubuntu 24.04) sends pixel copies over D-Bus; QEMU 11.1 uses the shared memory map
(`org.qemu.Display1.Listener.Unix.Map`).
- `dbus-daemon`, `gdbus`, `curl`, GNU binutils, and for the client: libcurl, OpenSSL, FFmpeg
(`libavcodec`, `libswscale`) development packages.
- `/dev/kvm` is optional; without it the VM runs under TCG (set a longer `E2E_TIMEOUT`).
## Running
```bash
cmake -B cmake-build-debug -G Ninja -DSUNSHINE_ENABLE_QEMU=ON -DBUILD_TESTS=ON
ninja -C cmake-build-debug sunshine
cmake -S tests/e2e/moonlight_client -B cmake-build-e2e-client -G Ninja
ninja -C cmake-build-e2e-client
tests/e2e/qemu/e2e_stream.sh
```
On success it prints `e2e: PASS` and a JSON summary:
```json
{"matched":true,"decoded_frames":31,"seconds":1.82,"host_latency_ms":{"samples":31,"p50":17.2,"p95":32.9},
"quadrants":[[253,0,0],[0,254,0],[0,0,254],[255,255,255]],"terminated":false}
```
`host_latency_ms` is the frame processing latency Sunshine reports in each video frame header: the
time from receiving the QEMU display call to sending the encoded frame.
On failure, or when `E2E_ARTIFACTS` is set, the Sunshine, QEMU, dbus-daemon and client logs, the
last decoded frame (`last_frame.ppm`) and `summary.json` are kept. See the header of
`qemu/e2e_stream.sh` for all environment variables (binaries, port, stream mode, frame count).
## Latency runs
```bash
SUNSHINE_BIN=cmake-build-release/sunshine E2E_WIDTH=1920 E2E_HEIGHT=1080 E2E_FPS=60 E2E_FRAMES=600 \
E2E_ARTIFACTS=/tmp/latency tests/e2e/qemu/e2e_stream.sh
```
Set `E2E_ENCODER=nvenc` (a Sunshine build with CUDA) or `E2E_ENCODER=vaapi` to measure a hardware
encoder instead of software x264. NVENC also works under WSL2; VAAPI and DMABUF need a DRM render
node, which WSL2 doesn't have.
▸
tests/unit/platform/linux/qemu/fake_qemu.h
+629
−0
@@ -1,0 +1,629 @@
/**
* @file tests/unit/platform/linux/qemu/fake_qemu.h
* @brief In-process fake of QEMU's D-Bus display, running on a private dbus-daemon.
* @details The fake exports `org.qemu.Display1.VM` and `org.qemu.Display1.Console` the same way
* QEMU's `ui/dbus-display.c` and `ui/dbus-console.c` do: it replies to `RegisterListener`, then
* acts as the authentication server on the peer-to-peer socket and drives the client's
* `org.qemu.Display1.Listener` object through proxies.
*/
#pragma once
#ifdef SUNSHINE_BUILD_QEMU
// standard includes
#include <chrono>
#include <condition_variable>
#include <csignal>
#include <cstdint>
#include <functional>
#include <future>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
// platform includes
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
// generated includes
extern "C" {
#include "qemu/dbus-display1.h"
}
namespace qemu_test {
using namespace std::literals;
/**
* @brief Poll a condition until it holds or a timeout expires.
*
* @param predicate Condition to wait for.
* @param timeout Maximum time to wait.
* @return True when the condition held before the timeout.
*/
inline bool wait_until(const std::function<bool()> &predicate, std::chrono::milliseconds timeout = 5s) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) {
return true;
}
std::this_thread::sleep_for(5ms);
}
return predicate();
}
/**
* @brief A dbus-daemon spawned for one test.
*/
class private_bus_t {
public:
private_bus_t() {
const gchar *argv[] = {"dbus-daemon", "--session", "--nofork", "--nopidfile", "--print-address=1", nullptr};
gint out_fd = -1;
GError *error = nullptr;
if (!g_spawn_async_with_pipes(nullptr, (gchar **) argv, nullptr, (GSpawnFlags) (G_SPAWN_SEARCH_PATH | G_SPAWN_DO_NOT_REAP_CHILD), nullptr, nullptr, &pid, nullptr, &out_fd, nullptr, &error)) {
g_clear_error(&error);
return;
}
char c;
while (read(out_fd, &c, 1) == 1 && c != '\n') {
bus_address.push_back(c);
}
close(out_fd);
}
~private_bus_t() {
if (pid > 0) {
kill(pid, SIGTERM);
waitpid(pid, nullptr, 0);
g_spawn_close_pid(pid);
}
}
private_bus_t(const private_bus_t &) = delete;
private_bus_t &operator=(const private_bus_t &) = delete;
/**
* @brief Report whether the daemon started.
*
* @return True when the bus address is known.
*/
[[nodiscard]] bool ok() const {
return !bus_address.empty();
}
/**
* @brief Access the bus address.
*
* @return D-Bus address clients connect to.
*/
[[nodiscard]] const std::string &address() const {
return bus_address;
}
private:
GPid pid {0}; ///< Daemon process id.
std::string bus_address; ///< Address printed by the daemon.
};
/**
* @brief Description of a console the fake exports.
*/
struct fake_console_t {
std::uint32_t id; ///< Console id.
std::string label; ///< Console label.
std::string type; ///< "Graphic" or "Text".
std::uint32_t width; ///< Width in pixels.
std::uint32_t height; ///< Height in pixels.
};
/**
* @brief Fake QEMU D-Bus display.
*/
class fake_qemu_t {
public:
/**
* @brief Start the fake and own `org.qemu` on the given bus.
*
* @param address Bus address.
* @param vm_name VM name property.
* @param vm_uuid VM UUID property.
* @param consoles Consoles to export.
*/
fake_qemu_t(const std::string &address, std::string vm_name, std::string vm_uuid, std::vector<fake_console_t> consoles):
context {g_main_context_new()},
loop {g_main_loop_new(context, FALSE)} {
thread = std::thread([this]() {
g_main_context_push_thread_default(context);
g_main_loop_run(loop);
g_main_context_pop_thread_default(context);
});
invoke([&]() {
GError *error = nullptr;
connection = g_dbus_connection_new_for_address_sync(address.c_str(), (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), nullptr, nullptr, &error);
if (!connection) {
g_clear_error(&error);
return;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
vm = qemu_dbus_display1_vm_skeleton_new();
std::vector<guint32> ids;
for (const auto &c : consoles) {
ids.push_back(c.id);
}
qemu_dbus_display1_vm_set_name(vm, vm_name.c_str());
qemu_dbus_display1_vm_set_uuid(vm, vm_uuid.c_str());
qemu_dbus_display1_vm_set_console_ids(vm, g_variant_new_fixed_array(G_VARIANT_TYPE_UINT32, ids.data(), ids.size(), sizeof(guint32)));
g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(vm), connection, "/org/qemu/Display1/VM", nullptr);
for (const auto &c : consoles) {
auto &state = console_states[c.id];
state.owner = this;
state.id = c.id;
state.skeleton = qemu_dbus_display1_console_skeleton_new();
qemu_dbus_display1_console_set_label(state.skeleton, c.label.c_str());
qemu_dbus_display1_console_set_type_(state.skeleton, c.type.c_str());
qemu_dbus_display1_console_set_width(state.skeleton, c.width);
qemu_dbus_display1_console_set_height(state.skeleton, c.height);
const gchar *interfaces[] = {"org.qemu.Display1.Keyboard", "org.qemu.Display1.Mouse", nullptr};
qemu_dbus_display1_console_set_interfaces(state.skeleton, interfaces);
g_signal_connect(state.skeleton, "handle-register-listener", G_CALLBACK(&fake_qemu_t::on_register_listener), &state);
auto path = "/org/qemu/Display1/Console_" + std::to_string(c.id);
g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(state.skeleton), connection, path.c_str(), nullptr);
}
auto reply = g_dbus_connection_call_sync(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", g_variant_new("(su)", "org.qemu", 4u), G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error);
if (!reply) {
g_clear_error(&error);
return;
}
g_variant_unref(reply);
started = true;
});
}
~fake_qemu_t() {
invoke([this]() {
for (auto &[id, state] : console_states) {
drop_listener_locked(state);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(state.skeleton));
g_object_unref(state.skeleton);
}
if (vm) {
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(vm));
g_object_unref(vm);
}
if (connection) {
g_dbus_connection_close_sync(connection, nullptr, nullptr);
g_object_unref(connection);
}
});
auto source = g_idle_source_new();
g_source_set_callback(
source,
[](gpointer data) -> gboolean {
g_main_loop_quit((GMainLoop *) data);
return G_SOURCE_REMOVE;
},
loop,
nullptr
);
g_source_attach(source, context);
g_source_unref(source);
thread.join();
g_main_loop_unref(loop);
g_main_context_unref(context);
}
fake_qemu_t(const fake_qemu_t &) = delete;
fake_qemu_t &operator=(const fake_qemu_t &) = delete;
/**
* @brief Report whether the fake owns `org.qemu`.
*
* @return True when startup succeeded.
*/
[[nodiscard]] bool ok() const {
return started;
}
/**
* @brief Wait for a client to register a listener on a console.
*
* @param console_id Console to check.
* @param timeout Maximum time to wait.
* @return True when a listener is registered.
*/
bool wait_for_listener(std::uint32_t console_id, std::chrono::milliseconds timeout = 5s) {
return wait_until(
[&]() {
std::lock_guard lock {mutex};
return console_states[console_id].proxy != nullptr;
},
timeout
);
}
/**
* @brief Read the listener's advertised `Interfaces` property.
*
* @param console_id Console whose listener to query.
* @return Interface names.
*/
std::vector<std::string> listener_interfaces(std::uint32_t console_id) {
std::lock_guard lock {mutex};
return console_states[console_id].listener_interfaces;
}
/**
* @brief Count how many listeners registered on a console so far.
*
* @param console_id Console to check.
* @return Number of successful registrations.
*/
int registrations(std::uint32_t console_id) {
std::lock_guard lock {mutex};
return console_states[console_id].registrations;
}
/**
* @brief Report whether the client closed the listener connection.
*
* @param console_id Console to check.
* @return True when the peer connection closed from the client side.
*/
bool listener_closed_by_peer(std::uint32_t console_id) {
std::lock_guard lock {mutex};
return console_states[console_id].closed_by_peer;
}
/**
* @brief Send a `Scanout` call and wait for the reply.
*
* @param console_id Target console.
* @param width Width in pixels.
* @param height Height in pixels.
* @param stride Bytes per row.
* @param format Pixman format code.
* @param data Pixel data.
* @return True when the client acknowledged the call.
*/
bool scanout(std::uint32_t console_id, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, const std::vector<std::uint8_t> &data) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, data.data(), data.size(), 1);
bool ok = qemu_dbus_display1_listener_call_scanout_sync(proxy, width, height, stride, format, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send an `Update` call and wait for the reply.
*
* @param console_id Target console.
* @param x Left edge.
* @param y Top edge.
* @param width Rectangle width.
* @param height Rectangle height.
* @param stride Bytes per row.
* @param format Pixman format code.
* @param data Pixel data for the rectangle.
* @return True when the client acknowledged the call.
*/
bool update(std::uint32_t console_id, std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, const std::vector<std::uint8_t> &data) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, data.data(), data.size(), 1);
bool ok = qemu_dbus_display1_listener_call_update_sync(proxy, x, y, width, height, stride, format, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send a `ScanoutMap` call with a shared memory descriptor and wait for the reply.
*
* @param console_id Target console.
* @param fd Descriptor to share; the caller keeps ownership.
* @param offset Offset of the first pixel.
* @param width Width in pixels.
* @param height Height in pixels.
* @param stride Bytes per row.
* @param format Pixman format code.
* @return True when the client acknowledged the call.
*/
bool scanout_map(std::uint32_t console_id, int fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) {
auto proxy = map_proxy(console_id);
if (!proxy) {
return false;
}
auto fd_list = g_unix_fd_list_new();
g_unix_fd_list_append(fd_list, fd, nullptr);
bool ok = qemu_dbus_display1_listener_unix_map_call_scanout_map_sync(proxy, g_variant_new_handle(0), offset, width, height, stride, format, G_DBUS_CALL_FLAGS_NONE, 5000, fd_list, nullptr, nullptr, nullptr);
g_object_unref(fd_list);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send an `UpdateMap` call and wait for the reply.
*
* @param console_id Target console.
* @param x Left edge.
* @param y Top edge.
* @param width Rectangle width.
* @param height Rectangle height.
* @return True when the client acknowledged the call.
*/
bool update_map(std::uint32_t console_id, std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) {
auto proxy = map_proxy(console_id);
if (!proxy) {
return false;
}
bool ok = qemu_dbus_display1_listener_unix_map_call_update_map_sync(proxy, x, y, width, height, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send a `Disable` call and wait for the reply.
*
* @param console_id Target console.
* @return True when the client acknowledged the call.
*/
bool disable(std::uint32_t console_id) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
bool ok = qemu_dbus_display1_listener_call_disable_sync(proxy, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send `MouseSet` and `CursorDefine` calls and wait for the replies.
*
* @param console_id Target console.
* @return True when the client acknowledged both calls.
*/
bool cursor(std::uint32_t console_id) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
std::vector<std::uint8_t> pixels(4 * 2 * 2, 0xff);
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, pixels.data(), pixels.size(), 1);
bool ok = qemu_dbus_display1_listener_call_mouse_set_sync(proxy, 10, 20, 1, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr) &&
qemu_dbus_display1_listener_call_cursor_define_sync(proxy, 2, 2, 1, 1, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send a `ScanoutDMABUF` call, which the client does not support yet.
*
* @param console_id Target console.
* @return True when the client acknowledged the call without an error.
*/
bool scanout_dmabuf(std::uint32_t console_id) {
auto proxy = listener_proxy(console_id);
if (!proxy) {
return false;
}
auto fd_list = g_unix_fd_list_new();
int fd = memfd_create("fake-dmabuf", MFD_CLOEXEC);
g_unix_fd_list_append(fd_list, fd, nullptr);
close(fd);
bool ok = qemu_dbus_display1_listener_call_scanout_dmabuf_sync(proxy, g_variant_new_handle(0), 16, 16, 64, 0x34325258, 0, TRUE, G_DBUS_CALL_FLAGS_NONE, 5000, fd_list, nullptr, nullptr, nullptr);
g_object_unref(fd_list);
g_object_unref(proxy);
return ok;
}
/**
* @brief Close the listener connection from the QEMU side, as when QEMU exits.
*
* @param console_id Target console.
*/
void drop_listener(std::uint32_t console_id) {
invoke([&]() {
std::lock_guard lock {mutex};
drop_listener_locked(console_states[console_id]);
});
}
/**
* @brief Give up ownership of `org.qemu`.
*/
void release_name() {
invoke([&]() {
auto reply = g_dbus_connection_call_sync(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ReleaseName", g_variant_new("(s)", "org.qemu"), G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, nullptr);
if (reply) {
g_variant_unref(reply);
}
});
}
private:
/**
* @brief Per-console fake state.
*/
struct console_state_t {
fake_qemu_t *owner {nullptr}; ///< Owning fake.
std::uint32_t id {0}; ///< Console id.
QemuDBusDisplay1Console *skeleton {nullptr}; ///< Exported console object.
GDBusConnection *peer {nullptr}; ///< Listener peer-to-peer connection.
QemuDBusDisplay1Listener *proxy {nullptr}; ///< Listener proxy.
QemuDBusDisplay1ListenerUnixMap *map {nullptr}; ///< Unix.Map proxy, when advertised.
std::vector<std::string> listener_interfaces; ///< Interfaces the listener advertised.
int registrations {0}; ///< Number of successful registrations.
bool closed_by_peer {false}; ///< Whether the client closed the connection.
};
/**
* @brief Run a function on the fake's thread and wait for it.
*
* @param fn Function to run.
*/
void invoke(const std::function<void()> &fn) {
std::packaged_task<void()> task {fn};
auto done = task.get_future();
auto source = g_idle_source_new();
g_source_set_callback(
source,
[](gpointer data) -> gboolean {
(*(std::packaged_task<void()> *) data)();
return G_SOURCE_REMOVE;
},
&task,
nullptr
);
g_source_attach(source, context);
g_source_unref(source);
done.get();
}
/**
* @brief Get a new reference to a console's listener proxy.
*
* @param console_id Console to look up.
* @return Proxy reference, or nullptr.
*/
QemuDBusDisplay1Listener *listener_proxy(std::uint32_t console_id) {
std::lock_guard lock {mutex};
auto proxy = console_states[console_id].proxy;
return proxy ? (QemuDBusDisplay1Listener *) g_object_ref(proxy) : nullptr;
}
/**
* @brief Get a new reference to a console's Unix.Map proxy.
*
* @param console_id Console to look up.
* @return Proxy reference, or nullptr.
*/
QemuDBusDisplay1ListenerUnixMap *map_proxy(std::uint32_t console_id) {
std::lock_guard lock {mutex};
auto proxy = console_states[console_id].map;
return proxy ? (QemuDBusDisplay1ListenerUnixMap *) g_object_ref(proxy) : nullptr;
}
/**
* @brief Close and forget a console's listener connection.
*
* @param state Console state; the caller holds the mutex or runs on the fake thread.
*/
void drop_listener_locked(console_state_t &state) {
if (!state.peer) {
return;
}
g_signal_handlers_disconnect_by_data(state.peer, &state);
g_clear_object(&state.proxy);
g_clear_object(&state.map);
g_dbus_connection_close_sync(state.peer, nullptr, nullptr);
g_clear_object(&state.peer);
}
/**
* @brief Record that the client side closed a listener connection.
*
* @param connection Closed connection.
* @param remote_peer_vanished Whether the peer closed it.
* @param error Close reason.
* @param data Console state.
*/
static void on_peer_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *error, gpointer data) {
auto state = (console_state_t *) data;
std::lock_guard lock {state->owner->mutex};
state->closed_by_peer = true;
}
/**
* @brief Handle `RegisterListener` like QEMU's `dbus_console_register_listener`.
*
* @param skeleton Console object.
* @param invocation Method invocation.
* @param fd_list Descriptors attached to the call.
* @param arg_listener Handle of the listener socket.
* @param data Console state.
* @return Always TRUE.
*/
static gboolean on_register_listener(QemuDBusDisplay1Console *skeleton, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *arg_listener, gpointer data) {
auto state = (console_state_t *) data;
GError *error = nullptr;
int fd = g_unix_fd_list_get(fd_list, g_variant_get_handle(arg_listener), &error);
if (fd < 0) {
g_dbus_method_invocation_return_gerror(invocation, error);
g_clear_error(&error);
return TRUE;
}
auto socket = g_socket_new_from_fd(fd, nullptr);
auto socket_connection = g_socket_connection_factory_create_connection(socket);
g_object_unref(socket);
qemu_dbus_display1_console_complete_register_listener(skeleton, invocation, nullptr);
auto guid = g_dbus_generate_guid();
auto peer = g_dbus_connection_new_sync(G_IO_STREAM(socket_connection), guid, G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_SERVER, nullptr, nullptr, &error);
g_free(guid);
g_object_unref(socket_connection);
if (!peer) {
g_clear_error(&error);
return TRUE;
}
auto proxy = qemu_dbus_display1_listener_proxy_new_sync(peer, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, nullptr, "/org/qemu/Display1/Listener", nullptr, &error);
if (!proxy) {
g_clear_error(&error);
g_object_unref(peer);
return TRUE;
}
std::vector<std::string> interfaces;
QemuDBusDisplay1ListenerUnixMap *map = nullptr;
if (auto names = qemu_dbus_display1_listener_get_interfaces(proxy)) {
for (auto name = names; *name; ++name) {
interfaces.emplace_back(*name);
}
if (g_strv_contains(names, "org.qemu.Display1.Listener.Unix.Map")) {
map = qemu_dbus_display1_listener_unix_map_proxy_new_sync(peer, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, nullptr, "/org/qemu/Display1/Listener", nullptr, nullptr);
}
}
g_signal_connect(peer, "closed", G_CALLBACK(&fake_qemu_t::on_peer_closed), state);
std::lock_guard lock {state->owner->mutex};
state->owner->drop_listener_locked(*state);
state->peer = peer;
state->proxy = proxy;
state->map = map;
state->listener_interfaces = std::move(interfaces);
state->registrations += 1;
state->closed_by_peer = false;
return TRUE;
}
GMainContext *context; ///< Fake's main context.
GMainLoop *loop; ///< Fake's main loop.
std::thread thread; ///< Thread running the loop.
GDBusConnection *connection {nullptr}; ///< Bus connection.
QemuDBusDisplay1VM *vm {nullptr}; ///< Exported VM object.
std::map<std::uint32_t, console_state_t> console_states; ///< Consoles by id.
std::mutex mutex; ///< Guards listener state read by the test thread.
bool started {false}; ///< Whether startup succeeded.
};
} // namespace qemu_test
#endif
▸
tests/unit/platform/linux/qemu/test_capture.cpp
+377
−0
@@ -1,0 +1,377 @@
/**
* @file tests/unit/platform/linux/qemu/test_capture.cpp
* @brief Test the QEMU capture backend against a fake QEMU.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
#include "fake_qemu.h"
// standard includes
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <thread>
// local includes
#include <src/config.h>
#include <src/platform/common.h>
#include <src/platform/linux/qemu/session.h>
#include <src/video.h>
using namespace std::literals;
namespace platf {
bool verify_qemu();
std::vector<std::string> qemu_display_names();
std::shared_ptr<display_t> qemu_display(mem_type_e hwdevice_type, const std::string &display_name, const video::config_t &config);
} // namespace platf
namespace {
/**
* @brief A 60 fps stream configuration.
*
* @return Video configuration.
*/
video::config_t stream_config() {
video::config_t config {};
config.width = 1280;
config.height = 720;
config.framerate = 60;
return config;
}
/**
* @brief Build a solid BGRX frame.
*
* @param width Width in pixels.
* @param height Height in pixels.
* @param b Blue.
* @param g Green.
* @param r Red.
* @return Pixel bytes with stride `4 * width`.
*/
std::vector<std::uint8_t> solid(int width, int height, std::uint8_t b, std::uint8_t g, std::uint8_t r) {
std::vector<std::uint8_t> data(width * height * 4);
for (int i = 0; i < width * height; ++i) {
data[i * 4] = b;
data[i * 4 + 1] = g;
data[i * 4 + 2] = r;
data[i * 4 + 3] = 0xff;
}
return data;
}
/**
* @brief Fixture with a fake QEMU and `capture = qemu` pointing at it.
*/
class QemuCaptureTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
saved_capture = config::video.capture;
saved_address = config::video.qemu_dbus_address;
bus = std::make_unique<qemu_test::private_bus_t>();
if (!bus->ok()) {
GTEST_SKIP() << "dbus-daemon is not available; REQ-CAP-001/REQ-CAP-002 capture tests need it";
}
fake = std::make_unique<qemu_test::fake_qemu_t>(
bus->address(),
"capture-vm",
"00000000-0000-0000-0000-000000000007",
std::vector<qemu_test::fake_console_t> {
{0, "serial0", "Text", 640, 480},
{1, "VGA", "Graphic", 64, 48},
{2, "virtio-gpu-pci.1", "Graphic", 32, 24},
}
);
ASSERT_TRUE(fake->ok());
config::video.capture = "qemu";
config::video.qemu_dbus_address = bus->address();
}
void TearDown() override {
config::video.capture = saved_capture;
config::video.qemu_dbus_address = saved_address;
fake.reset();
bus.reset();
BaseTest::TearDown();
}
/**
* @brief Create a display while the fake sends the initial scanout, like QEMU does.
*
* @param display_name Console to open.
* @param console_id Console the fake scans out on.
* @param width Scanout width.
* @param height Scanout height.
* @return Display, or nullptr.
*/
std::shared_ptr<platf::display_t> open_display(const std::string &display_name, std::uint32_t console_id, int width, int height) {
std::thread sender {[&, console_id, width, height]() {
if (fake->wait_for_listener(console_id)) {
fake->scanout(console_id, width, height, width * 4, qemu::pixman_format::x8r8g8b8, solid(width, height, 0x10, 0x20, 0x30));
}
}};
auto display = platf::qemu_display(platf::mem_type_e::system, display_name, stream_config());
sender.join();
return display;
}
/**
* @brief Run capture until `on_frame` returns false, or about 10 seconds of 60 fps ticks pass.
*
* @param display Display to capture from.
* @param on_frame Called for each pushed image; return false to stop.
* @return Capture status; `ok` also when the tick limit stopped the capture.
*/
platf::capture_e run_capture(platf::display_t &display, const std::function<bool(std::shared_ptr<platf::img_t> &&, bool)> &on_frame) {
int ticks = 0;
auto bounded = [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
return ++ticks < 600 && on_frame(std::move(img), frame_captured);
};
std::vector<std::shared_ptr<platf::img_t>> pool;
auto pull = [&](std::shared_ptr<platf::img_t> &img_out) -> bool {
for (auto &img : pool) {
if (img.use_count() == 1) {
img_out = img;
return true;
}
}
pool.push_back(display.alloc_img());
img_out = pool.back();
return true;
};
bool cursor = false;
return display.capture(bounded, pull, &cursor);
}
std::unique_ptr<qemu_test::private_bus_t> bus;
std::unique_ptr<qemu_test::fake_qemu_t> fake;
std::string saved_capture;
std::string saved_address;
};
} // namespace
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, VerifyAndListGraphicConsoles) {
EXPECT_TRUE(platf::verify_qemu());
EXPECT_EQ(platf::qemu_display_names(), (std::vector<std::string> {"1", "2"}));
}
// @tag requirements: [REQ-CAP-002, REQ-E2E-001]
TEST_F(QemuCaptureTest, CapturesScanoutFrames) {
auto display = open_display("1", 1, 8, 4);
ASSERT_NE(display, nullptr);
EXPECT_EQ(display->width, 8);
EXPECT_EQ(display->height, 4);
EXPECT_EQ(display->env_width, 8);
EXPECT_EQ(display->env_height, 4);
std::vector<std::uint8_t> captured;
std::optional<std::chrono::steady_clock::time_point> timestamp;
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (!frame_captured) {
return true;
}
captured.assign(img->data, img->data + img->height * img->row_pitch);
timestamp = img->frame_timestamp;
return false;
});
EXPECT_EQ(status, platf::capture_e::ok);
ASSERT_EQ(captured.size(), 8 * 4 * 4);
EXPECT_EQ(captured[0], 0x10);
EXPECT_EQ(captured[1], 0x20);
EXPECT_EQ(captured[2], 0x30);
EXPECT_TRUE(timestamp.has_value());
}
// @tag requirements: [REQ-CAP-002]
TEST_F(QemuCaptureTest, PushesOnlyWhenTheGuestChangesTheFrame) {
auto display = open_display("", 1, 4, 4);
ASSERT_NE(display, nullptr);
int captured = 0;
int idle = 0;
std::thread painter;
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&img, bool frame_captured) {
if (frame_captured) {
captured += 1;
if (captured == 1) {
painter = std::thread {[&]() {
std::this_thread::sleep_for(100ms);
fake->update(1, 0, 0, 1, 1, 4, qemu::pixman_format::x8r8g8b8, {0xaa, 0xbb, 0xcc, 0xff});
}};
} else {
EXPECT_EQ(img->data[0], 0xaa);
return false;
}
} else {
idle += 1;
}
return idle < 600;
});
if (painter.joinable()) {
painter.join();
}
EXPECT_EQ(status, platf::capture_e::ok);
EXPECT_EQ(captured, 2);
EXPECT_GT(idle, 0);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, ResizeRequestsReinit) {
auto display = open_display("1", 1, 4, 4);
ASSERT_NE(display, nullptr);
std::thread resizer {[&]() {
std::this_thread::sleep_for(50ms);
fake->scanout(1, 8, 8, 32, qemu::pixman_format::x8r8g8b8, solid(8, 8, 0, 0, 0));
}};
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&, bool) {
return true;
});
resizer.join();
EXPECT_EQ(status, platf::capture_e::reinit);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, VmGoneRequestsReinit) {
auto display = open_display("1", 1, 4, 4);
ASSERT_NE(display, nullptr);
std::thread dropper {[&]() {
std::this_thread::sleep_for(50ms);
fake->drop_listener(1);
}};
auto status = run_capture(*display, [&](std::shared_ptr<platf::img_t> &&, bool) {
return true;
});
dropper.join();
EXPECT_EQ(status, platf::capture_e::reinit);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, UnknownConsoleFallsBackToFirstGraphicConsole) {
auto display = open_display("HDMI-1", 1, 6, 2);
ASSERT_NE(display, nullptr);
EXPECT_EQ(display->width, 6);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, UsesConsoleSizeWhenNoScanoutArrives) {
auto display = platf::qemu_display(platf::mem_type_e::system, "2", stream_config());
ASSERT_NE(display, nullptr);
EXPECT_EQ(display->width, 32);
EXPECT_EQ(display->height, 24);
}
// @tag requirements: [REQ-CAP-002]
TEST_F(QemuCaptureTest, AllocatesBgrxImagesAndBlackDummy) {
auto display = open_display("1", 1, 4, 2);
ASSERT_NE(display, nullptr);
auto img = display->alloc_img();
ASSERT_NE(img, nullptr);
EXPECT_EQ(img->width, 4);
EXPECT_EQ(img->height, 2);
EXPECT_EQ(img->pixel_pitch, 4);
EXPECT_EQ(img->row_pitch, 16);
std::memset(img->data, 0x7f, 16 * 2);
EXPECT_EQ(display->dummy_img(img.get()), 0);
EXPECT_EQ(img->data[5], 0);
EXPECT_EQ(display->dummy_img(nullptr), -1);
EXPECT_NE(display->make_avcodec_encode_device(platf::pix_fmt_e::nv12), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, RejectsUnsupportedMemoryType) {
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::vulkan, "1", stream_config()), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuCaptureTest, FailsWhenQemuIsUnreachable) {
config::video.qemu_dbus_address = "unix:path=/nonexistent/sunshine-qemu-capture.sock";
EXPECT_FALSE(platf::verify_qemu());
EXPECT_TRUE(platf::qemu_display_names().empty());
EXPECT_EQ(platf::qemu_display(platf::mem_type_e::system, "1", stream_config()), nullptr);
}
/**
* @brief Fixture that listens on a Unix socket to detect connection attempts.
*/
class QemuNotSelectedTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
saved_capture = config::video.capture;
saved_address = config::video.qemu_dbus_address;
// Unix socket paths are limited to 108 bytes, so don't use the (long) build directory
char dir_template[] = "/tmp/sunshine-qemu-cmp-XXXXXX";
ASSERT_NE(mkdtemp(dir_template), nullptr);
dir = dir_template;
path = dir + "/bus.sock";
listen_fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
ASSERT_GE(listen_fd, 0);
sockaddr_un addr {};
addr.sun_family = AF_UNIX;
ASSERT_LT(path.size(), sizeof(addr.sun_path));
std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1);
ASSERT_EQ(bind(listen_fd, (sockaddr *) &addr, sizeof(addr)), 0);
ASSERT_EQ(listen(listen_fd, 4), 0);
config::video.qemu_dbus_address = "unix:path=" + path;
}
void TearDown() override {
if (listen_fd >= 0) {
close(listen_fd);
}
unlink(path.c_str());
rmdir(dir.c_str());
config::video.capture = saved_capture;
config::video.qemu_dbus_address = saved_address;
BaseTest::TearDown();
}
/**
* @brief Report whether anything connected to the socket.
*
* @return True when a pending connection was accepted.
*/
bool connection_attempted() const {
int fd = accept(listen_fd, nullptr, nullptr);
if (fd >= 0) {
close(fd);
return true;
}
return false;
}
int listen_fd {-1};
std::string dir;
std::string path;
std::string saved_capture;
std::string saved_address;
};
// @tag requirements: [REQ-CMP-001]
TEST_F(QemuNotSelectedTest, NeverConnectsUnlessCaptureIsQemu) {
for (auto capture : {"", "kms", "x11", "wlr", "portal", "kwin", "nvfbc", "QEMU"}) {
config::video.capture = capture;
EXPECT_FALSE(platf::verify_qemu()) << "capture = " << capture;
EXPECT_FALSE(connection_attempted()) << "capture = " << capture;
}
}
// @tag requirements: [REQ-CMP-001]
TEST_F(QemuNotSelectedTest, SelectingQemuDoesConnect) {
config::video.capture = "qemu";
EXPECT_FALSE(platf::verify_qemu()); // the socket is not a D-Bus server
EXPECT_TRUE(connection_attempted());
}
#endif
▸
tests/unit/platform/linux/qemu/test_frame_store.cpp
+284
−0
@@ -1,0 +1,284 @@
/**
* @file tests/unit/platform/linux/qemu/test_frame_store.cpp
* @brief Test reconstruction of the guest framebuffer from QEMU listener calls.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
// standard includes
#include <array>
#include <thread>
// platform includes
#include <sys/mman.h>
#include <unistd.h>
// local includes
#include <src/platform/linux/qemu/frame_store.h>
using namespace std::literals;
namespace {
/**
* @brief Build a pixel buffer with a given stride where each pixel encodes its position.
*
* @param width Width in pixels.
* @param height Height in pixels.
* @param stride Bytes per row, at least `4 * width`.
* @param order Byte order of the channels, as indices of B, G, R, X within a pixel.
* @return Pixel bytes with padding bytes set to 0xee.
*/
std::vector<std::uint8_t> make_pixels(int width, int height, int stride, std::array<int, 4> order = {0, 1, 2, 3}) {
std::vector<std::uint8_t> data(stride * height, 0xee);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
auto p = &data[y * stride + x * 4];
p[order[0]] = (std::uint8_t) (x * 16); // B
p[order[1]] = (std::uint8_t) (y * 16); // G
p[order[2]] = (std::uint8_t) (0x80 | x | (y << 4)); // R
p[order[3]] = 0xff; // X
}
}
return data;
}
/**
* @brief Read one BGR pixel from a tight BGRX buffer.
*
* @param frame Frame bytes.
* @param width Frame width.
* @param x Pixel X.
* @param y Pixel Y.
* @return B, G, R values.
*/
std::array<std::uint8_t, 3> bgr_at(const std::vector<std::uint8_t> &frame, int width, int x, int y) {
auto p = &frame[(y * width + x) * 4];
return {p[0], p[1], p[2]};
}
std::array<std::uint8_t, 3> expected_bgr(int x, int y) {
return {(std::uint8_t) (x * 16), (std::uint8_t) (y * 16), (std::uint8_t) (0x80 | x | (y << 4))};
}
} // namespace
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, ScanoutCopiesX8R8G8B8WithStridePadding) {
qemu::frame_store_t store;
EXPECT_FALSE(store.wait_for_frame(0ms));
auto data = make_pixels(3, 2, 16);
store.scanout(3, 2, 16, qemu::pixman_format::x8r8g8b8, data);
ASSERT_TRUE(store.wait_for_frame(0ms));
EXPECT_EQ(store.width(), 3);
EXPECT_EQ(store.height(), 2);
std::vector<std::uint8_t> frame(3 * 2 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 3, 2, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(seq, store.sequence());
EXPECT_LE(timestamp, std::chrono::steady_clock::now());
for (int y = 0; y < 2; ++y) {
for (int x = 0; x < 3; ++x) {
EXPECT_EQ(bgr_at(frame, 3, x, y), expected_bgr(x, y)) << x << "," << y;
}
}
EXPECT_EQ(store.copy_if_newer(seq, 3, 2, frame.data(), timestamp), qemu::frame_status_e::unchanged);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, ScanoutConvertsAllSupportedFormats) {
struct format_case {
std::uint32_t format;
std::array<int, 4> order;
};
for (auto [format, order] : {
format_case {qemu::pixman_format::x8r8g8b8, {0, 1, 2, 3}},
format_case {qemu::pixman_format::a8r8g8b8, {0, 1, 2, 3}},
format_case {qemu::pixman_format::x8b8g8r8, {2, 1, 0, 3}},
format_case {qemu::pixman_format::a8b8g8r8, {2, 1, 0, 3}},
}) {
qemu::frame_store_t store;
store.scanout(2, 2, 8, format, make_pixels(2, 2, 8, order));
std::vector<std::uint8_t> frame(2 * 2 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 2, 2, frame.data(), timestamp), qemu::frame_status_e::new_frame) << std::hex << format;
EXPECT_EQ(bgr_at(frame, 2, 1, 1), expected_bgr(1, 1)) << std::hex << format;
}
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, IgnoresUnsupportedFormatAndShortData) {
qemu::frame_store_t store;
store.scanout(2, 2, 4, 0x10020565, std::vector<std::uint8_t>(8)); // r5g6b5
EXPECT_FALSE(store.wait_for_frame(0ms));
store.scanout(2, 2, 8, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(12)); // too short
EXPECT_FALSE(store.wait_for_frame(0ms));
store.scanout(2, 2, 4, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(16)); // stride too small
EXPECT_FALSE(store.wait_for_frame(0ms));
store.update(0, 0, 1, 1, 4, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(4)); // update before scanout
EXPECT_EQ(store.sequence(), 0);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, UpdateBlitsDamageRectangleAndClips) {
qemu::frame_store_t store;
store.scanout(4, 3, 16, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(4 * 3 * 4, 0));
auto after_scanout = store.sequence();
std::vector<std::uint8_t> rect(2 * 1 * 4);
for (int i = 0; i < 2; ++i) {
rect[i * 4] = 0x11;
rect[i * 4 + 1] = 0x22;
rect[i * 4 + 2] = 0x33;
}
store.update(1, 2, 2, 1, 8, qemu::pixman_format::x8r8g8b8, rect);
EXPECT_GT(store.sequence(), after_scanout);
// entirely outside of the frame: ignored
auto before_outside = store.sequence();
store.update(10, 10, 2, 1, 8, qemu::pixman_format::x8r8g8b8, rect);
EXPECT_EQ(store.sequence(), before_outside);
// partially outside: clipped to the right edge
store.update(3, 0, 2, 1, 8, qemu::pixman_format::x8r8g8b8, rect);
std::vector<std::uint8_t> frame(4 * 3 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 4, 3, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 4, 0, 2), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 4, 1, 2), (std::array<std::uint8_t, 3> {0x11, 0x22, 0x33}));
EXPECT_EQ(bgr_at(frame, 4, 2, 2), (std::array<std::uint8_t, 3> {0x11, 0x22, 0x33}));
EXPECT_EQ(bgr_at(frame, 4, 3, 2), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 4, 2, 0), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 4, 3, 0), (std::array<std::uint8_t, 3> {0x11, 0x22, 0x33}));
// an update with insufficient data is rejected
auto before_short = store.sequence();
store.update(0, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8, rect);
EXPECT_EQ(store.sequence(), before_short);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, SharedMapScanoutAndDamage) {
const int width = 3;
const int height = 2;
const int stride = 12;
const std::uint32_t offset = 4096;
const std::size_t size = offset + stride * height;
int fd = memfd_create("frame-store-test", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, size), 0);
auto map = (std::uint8_t *) mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
ASSERT_NE(map, MAP_FAILED);
auto pixels = make_pixels(width, height, stride);
std::copy(pixels.begin(), pixels.end(), map + offset);
qemu::frame_store_t store;
store.scanout_map(qemu::fd_t {dup(fd)}, offset, width, height, stride, qemu::pixman_format::x8r8g8b8);
std::vector<std::uint8_t> frame(width * height * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, width, height, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, width, 2, 1), expected_bgr(2, 1));
// the guest draws into the shared memory, then QEMU reports the damage
map[offset + stride * 1 + 4 * 1] = 0x42;
EXPECT_EQ(store.copy_if_newer(seq, width, height, frame.data(), timestamp), qemu::frame_status_e::unchanged);
store.update_map(1, 1, 1, 1);
ASSERT_EQ(store.copy_if_newer(seq, width, height, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, width, 1, 1)[0], 0x42);
// damage outside the frame is ignored
auto before = store.sequence();
store.update_map(5, 5, 1, 1);
EXPECT_EQ(store.sequence(), before);
// a message scanout replaces the map; later UpdateMap calls are ignored
store.scanout(width, height, stride, qemu::pixman_format::x8r8g8b8, pixels);
before = store.sequence();
store.update_map(0, 0, 1, 1);
EXPECT_EQ(store.sequence(), before);
munmap(map, size);
close(fd);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, SharedMapRejectsBadDescriptorOrGeometry) {
qemu::frame_store_t store;
store.scanout_map(qemu::fd_t {}, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8);
EXPECT_FALSE(store.wait_for_frame(0ms));
// file smaller than the advertised geometry: mapping it would fault on read
int small_fd = memfd_create("frame-store-small", MFD_CLOEXEC);
ASSERT_GE(small_fd, 0);
ASSERT_EQ(ftruncate(small_fd, 4), 0);
store.scanout_map(qemu::fd_t {small_fd}, 0, 2, 2, 8, qemu::pixman_format::x8r8g8b8);
EXPECT_FALSE(store.wait_for_frame(0ms));
int fd = memfd_create("frame-store-format", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, 64), 0);
store.scanout_map(qemu::fd_t {fd}, 0, 2, 2, 8, 0x10020565);
EXPECT_FALSE(store.wait_for_frame(0ms));
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, ReportsSizeChange) {
qemu::frame_store_t store;
store.scanout(2, 2, 8, qemu::pixman_format::x8r8g8b8, make_pixels(2, 2, 8));
store.scanout(4, 2, 16, qemu::pixman_format::x8r8g8b8, make_pixels(4, 2, 16));
std::vector<std::uint8_t> frame(2 * 2 * 4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
EXPECT_EQ(store.copy_if_newer(seq, 2, 2, frame.data(), timestamp), qemu::frame_status_e::size_changed);
EXPECT_EQ(seq, 0);
EXPECT_EQ(store.width(), 4);
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFrameStoreTest, DisableBlanksFrame) {
qemu::frame_store_t store;
store.scanout(2, 1, 8, qemu::pixman_format::x8r8g8b8, std::vector<std::uint8_t>(8, 0x7f));
store.disable();
std::vector<std::uint8_t> frame(2 * 1 * 4, 0x55);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
ASSERT_EQ(store.copy_if_newer(seq, 2, 1, frame.data(), timestamp), qemu::frame_status_e::new_frame);
EXPECT_EQ(bgr_at(frame, 2, 0, 0), (std::array<std::uint8_t, 3> {0, 0, 0}));
EXPECT_EQ(bgr_at(frame, 2, 1, 0), (std::array<std::uint8_t, 3> {0, 0, 0}));
}
// @tag requirements: [REQ-CAP-001]
TEST(QemuFrameStoreTest, DisconnectWakesWaitersAndIsReported) {
qemu::frame_store_t store;
EXPECT_TRUE(store.connected());
std::thread disconnector {[&]() {
std::this_thread::sleep_for(20ms);
store.disconnected();
}};
EXPECT_FALSE(store.wait_for_frame(5s));
disconnector.join();
EXPECT_FALSE(store.connected());
std::vector<std::uint8_t> frame(4);
std::uint64_t seq = 0;
std::chrono::steady_clock::time_point timestamp;
EXPECT_EQ(store.copy_if_newer(seq, 1, 1, frame.data(), timestamp), qemu::frame_status_e::disconnected);
}
#endif
▸
tests/unit/platform/linux/qemu/test_session.cpp
+448
−0
@@ -1,0 +1,448 @@
/**
* @file tests/unit/platform/linux/qemu/test_session.cpp
* @brief Test the QEMU D-Bus display session against a fake QEMU.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
#include "fake_qemu.h"
// standard includes
#include <atomic>
#include <cstring>
#include <fcntl.h>
#include <mutex>
// local includes
#include <src/platform/linux/qemu/session.h>
using namespace std::literals;
namespace {
/**
* @brief Listener that records what the session delivers.
*/
struct recording_listener_t: qemu::display_listener_t {
std::mutex mutex;
std::vector<std::string> calls;
std::vector<std::uint8_t> last_data;
std::uint32_t last_width {0};
std::uint32_t last_height {0};
std::uint32_t last_stride {0};
std::uint32_t last_format {0};
std::int32_t last_x {0};
std::int32_t last_y {0};
std::uint32_t last_offset {0};
std::vector<std::uint8_t> mapped_bytes;
std::atomic<int> disconnects {0};
void scanout(std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
calls.emplace_back("scanout");
last_width = width;
last_height = height;
last_stride = stride;
last_format = format;
last_data.assign(data.begin(), data.end());
}
void update(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint32_t stride, std::uint32_t format, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
calls.emplace_back("update");
last_x = x;
last_y = y;
last_width = width;
last_height = height;
last_stride = stride;
last_format = format;
last_data.assign(data.begin(), data.end());
}
void scanout_map(qemu::fd_t fd, std::uint32_t offset, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint32_t format) override {
std::lock_guard lock {mutex};
calls.emplace_back("scanout_map");
last_offset = offset;
last_width = width;
last_height = height;
last_stride = stride;
last_format = format;
auto size = offset + stride * height;
auto addr = mmap(nullptr, size, PROT_READ, MAP_SHARED, fd.get(), 0);
if (addr != MAP_FAILED) {
auto bytes = (const std::uint8_t *) addr;
mapped_bytes.assign(bytes + offset, bytes + size);
munmap(addr, size);
}
}
void update_map(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) override {
std::lock_guard lock {mutex};
calls.emplace_back("update_map");
last_x = x;
last_y = y;
last_width = width;
last_height = height;
}
void disable() override {
std::lock_guard lock {mutex};
calls.emplace_back("disable");
}
void mouse_set(std::int32_t x, std::int32_t y, bool visible) override {
std::lock_guard lock {mutex};
calls.emplace_back("mouse_set");
}
void cursor_define(std::int32_t width, std::int32_t height, std::int32_t hot_x, std::int32_t hot_y, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
calls.emplace_back("cursor_define");
}
void disconnected() override {
disconnects += 1;
}
std::vector<std::string> snapshot_calls() {
std::lock_guard lock {mutex};
return calls;
}
};
/**
* @brief Fixture that provides a private bus and a fake QEMU with two consoles.
*/
class QemuSessionTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
bus = std::make_unique<qemu_test::private_bus_t>();
if (!bus->ok()) {
GTEST_SKIP() << "dbus-daemon is not available; REQ-CAP-001 session tests need it";
}
}
void TearDown() override {
fake.reset();
bus.reset();
BaseTest::TearDown();
}
void start_fake() {
fake = std::make_unique<qemu_test::fake_qemu_t>(
bus->address(),
"test-vm",
"00000000-0000-0000-0000-000000000042",
std::vector<qemu_test::fake_console_t> {
{0, "serial0", "Text", 640, 480},
{1, "VGA", "Graphic", 1024, 768},
{2, "virtio-gpu-pci.1", "Graphic", 800, 600},
}
);
ASSERT_TRUE(fake->ok());
}
std::unique_ptr<qemu_test::private_bus_t> bus;
std::unique_ptr<qemu_test::fake_qemu_t> fake;
};
} // namespace
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ConnectDiscoversVmAndConsoles) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
EXPECT_TRUE(session->alive());
auto vm = session->vm();
EXPECT_EQ(vm.name, "test-vm");
EXPECT_EQ(vm.uuid, "00000000-0000-0000-0000-000000000042");
ASSERT_EQ(vm.consoles.size(), 3);
EXPECT_EQ(vm.consoles[0].id, 0);
EXPECT_EQ(vm.consoles[0].label, "serial0");
EXPECT_FALSE(vm.consoles[0].is_graphic());
EXPECT_EQ(vm.consoles[1].id, 1);
EXPECT_EQ(vm.consoles[1].label, "VGA");
EXPECT_TRUE(vm.consoles[1].is_graphic());
EXPECT_EQ(vm.consoles[1].width, 1024);
EXPECT_EQ(vm.consoles[1].height, 768);
EXPECT_NE(std::ranges::find(vm.consoles[1].interfaces, "org.qemu.Display1.Keyboard"), vm.consoles[1].interfaces.end());
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ConnectFailsWhenQemuDoesNotOwnTheName) {
auto start = std::chrono::steady_clock::now();
auto session = qemu::session_t::connect(bus->address(), 2s);
EXPECT_EQ(session, nullptr);
EXPECT_LT(std::chrono::steady_clock::now() - start, 2s);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ConnectFailsForUnreachableAddress) {
EXPECT_EQ(qemu::session_t::connect("unix:path=/nonexistent/sunshine-qemu-test.sock", 1s), nullptr);
EXPECT_EQ(qemu::session_t::connect("not a dbus address", 1s), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, FindConsoleByIdLabelOrDefault) {
qemu::vm_info_t vm;
vm.consoles = {
{0, "serial0", "Text"},
{1, "VGA", "Graphic"},
{2, "virtio-gpu-pci.1", "Graphic"},
};
EXPECT_EQ(qemu::find_console(vm, "")->id, 1);
EXPECT_EQ(qemu::find_console(vm, "2")->id, 2);
EXPECT_EQ(qemu::find_console(vm, "VGA")->id, 1);
EXPECT_EQ(qemu::find_console(vm, "virtio-gpu-pci.1")->id, 2);
EXPECT_FALSE(qemu::find_console(vm, "7").has_value());
EXPECT_FALSE(qemu::find_console(vm, "HDMI").has_value());
EXPECT_FALSE(qemu::find_console(qemu::vm_info_t {}, "").has_value());
EXPECT_EQ(qemu::graphic_console_names(vm), (std::vector<std::string> {"1", "2"}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, RegisterListenerAdvertisesUnixMap) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
auto interfaces = fake->listener_interfaces(1);
EXPECT_NE(std::ranges::find(interfaces, "org.qemu.Display1.Listener.Unix.Map"), interfaces.end());
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, RegisterListenerFailsForUnknownConsole) {
start_fake();
auto session = qemu::session_t::connect(bus->address(), 1s);
ASSERT_NE(session, nullptr);
EXPECT_EQ(session->register_listener(9, std::make_shared<recording_listener_t>()), nullptr);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DeliversScanoutAndUpdate) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
std::vector<std::uint8_t> frame(4 * 4 * 2);
for (std::size_t i = 0; i < frame.size(); ++i) {
frame[i] = (std::uint8_t) i;
}
ASSERT_TRUE(fake->scanout(1, 4, 2, 16, qemu::pixman_format::x8r8g8b8, frame));
{
std::lock_guard lock {listener->mutex};
EXPECT_EQ(listener->calls.back(), "scanout");
EXPECT_EQ(listener->last_width, 4);
EXPECT_EQ(listener->last_height, 2);
EXPECT_EQ(listener->last_stride, 16);
EXPECT_EQ(listener->last_format, qemu::pixman_format::x8r8g8b8);
EXPECT_EQ(listener->last_data, frame);
}
std::vector<std::uint8_t> rect(4, 0xab);
ASSERT_TRUE(fake->update(1, 3, 1, 1, 1, 4, qemu::pixman_format::a8r8g8b8, rect));
std::lock_guard lock {listener->mutex};
EXPECT_EQ(listener->calls.back(), "update");
EXPECT_EQ(listener->last_x, 3);
EXPECT_EQ(listener->last_y, 1);
EXPECT_EQ(listener->last_width, 1);
EXPECT_EQ(listener->last_height, 1);
EXPECT_EQ(listener->last_format, qemu::pixman_format::a8r8g8b8);
EXPECT_EQ(listener->last_data, rect);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DeliversSharedMapScanoutWithDescriptor) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
const std::uint32_t offset = 8;
const std::uint32_t width = 2;
const std::uint32_t height = 2;
const std::uint32_t stride = 8;
int fd = memfd_create("fake-scanout", MFD_CLOEXEC);
ASSERT_GE(fd, 0);
ASSERT_EQ(ftruncate(fd, offset + stride * height), 0);
std::vector<std::uint8_t> pixels(stride * height);
for (std::size_t i = 0; i < pixels.size(); ++i) {
pixels[i] = (std::uint8_t) (0x10 + i);
}
ASSERT_EQ(pwrite(fd, pixels.data(), pixels.size(), offset), (ssize_t) pixels.size());
ASSERT_TRUE(fake->scanout_map(1, fd, offset, width, height, stride, qemu::pixman_format::x8r8g8b8));
close(fd);
ASSERT_TRUE(fake->update_map(1, 1, 0, 1, 2));
std::lock_guard lock {listener->mutex};
ASSERT_EQ(listener->calls, (std::vector<std::string> {"scanout_map", "update_map"}));
EXPECT_EQ(listener->last_offset, offset);
EXPECT_EQ(listener->mapped_bytes, pixels);
EXPECT_EQ(listener->last_x, 1);
EXPECT_EQ(listener->last_y, 0);
EXPECT_EQ(listener->last_width, 1);
EXPECT_EQ(listener->last_height, 2);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DeliversDisableAndCursor) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
ASSERT_TRUE(fake->disable(1));
ASSERT_TRUE(fake->cursor(1));
EXPECT_EQ(listener->snapshot_calls(), (std::vector<std::string> {"disable", "mouse_set", "cursor_define"}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, AcknowledgesUnsupportedDmabufScanout) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
EXPECT_TRUE(fake->scanout_dmabuf(1));
EXPECT_TRUE(listener->snapshot_calls().empty());
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, ReportsListenerDisconnectWhenQemuDropsIt) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
fake->drop_listener(1);
EXPECT_TRUE(qemu_test::wait_until([&]() {
return listener->disconnects == 1;
}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, SessionNotAliveWhenQemuReleasesName) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
ASSERT_TRUE(session->alive());
fake->release_name();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return !session->alive();
}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, SessionNotAliveWhenBusGoesAway) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
fake.reset();
bus.reset();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return !session->alive();
}));
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, DestroyingRegistrationClosesListenerAndStopsCallbacks) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto listener = std::make_shared<recording_listener_t>();
auto registration = session->register_listener(1, listener);
ASSERT_NE(registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
registration.reset();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return fake->listener_closed_by_peer(1);
}));
EXPECT_FALSE(fake->scanout(1, 1, 1, 4, qemu::pixman_format::x8r8g8b8, {0, 0, 0, 0}));
EXPECT_TRUE(listener->snapshot_calls().empty());
EXPECT_EQ(listener->disconnects, 0);
}
// @tag requirements: [REQ-CAP-001]
TEST_F(QemuSessionTest, SupportsMultipleListenersAndReregistration) {
start_fake();
auto session = qemu::session_t::connect(bus->address());
ASSERT_NE(session, nullptr);
auto first = std::make_shared<recording_listener_t>();
auto second = std::make_shared<recording_listener_t>();
auto first_registration = session->register_listener(1, first);
auto second_registration = session->register_listener(2, second);
ASSERT_NE(first_registration, nullptr);
ASSERT_NE(second_registration, nullptr);
ASSERT_TRUE(fake->wait_for_listener(1));
ASSERT_TRUE(fake->wait_for_listener(2));
ASSERT_TRUE(fake->disable(2));
EXPECT_TRUE(first->snapshot_calls().empty());
EXPECT_EQ(second->snapshot_calls(), (std::vector<std::string> {"disable"}));
first_registration.reset();
auto again = std::make_shared<recording_listener_t>();
auto again_registration = session->register_listener(1, again);
ASSERT_NE(again_registration, nullptr);
EXPECT_TRUE(qemu_test::wait_until([&]() {
return fake->registrations(1) == 2;
}));
ASSERT_TRUE(fake->disable(1));
EXPECT_EQ(again->snapshot_calls(), (std::vector<std::string> {"disable"}));
}
// @tag requirements: [REQ-CAP-002]
TEST(QemuFdTest, OwnsAndReleasesDescriptor) {
int fds[2];
ASSERT_EQ(pipe(fds), 0);
close(fds[1]);
{
qemu::fd_t owner {fds[0]};
EXPECT_EQ(owner.get(), fds[0]);
qemu::fd_t moved {std::move(owner)};
EXPECT_EQ(owner.get(), -1);
EXPECT_EQ(moved.get(), fds[0]);
qemu::fd_t assigned;
assigned = std::move(moved);
EXPECT_EQ(assigned.get(), fds[0]);
int raw = assigned.release();
EXPECT_EQ(raw, fds[0]);
EXPECT_EQ(assigned.get(), -1);
qemu::fd_t reowned {raw};
}
EXPECT_EQ(fcntl(fds[0], F_GETFD), -1);
}
#endif
▸
third-party/qemu-dbus-display/dbus-display1.xml
+1027
−0
@@ -1,0 +1,1027 @@
<?xml version="1.0" ?>
<node>
<!--
org.qemu.Display1.VM:
This interface is implemented on ``/org/qemu/Display1/VM``.
-->
<interface name="org.qemu.Display1.VM">
<!--
Name:
The name of the VM.
-->
<property name="Name" type="s" access="read"/>
<!--
UUID:
The UUID of the VM.
-->
<property name="UUID" type="s" access="read"/>
<!--
ConsoleIDs:
The list of consoles available on ``/org/qemu/Display1/Console_$id``.
-->
<property name="ConsoleIDs" type="au" access="read"/>
<!--
Interfaces:
This property lists extra interfaces provided by the
/org/qemu/Display1/VM object, and can be used to detect
the capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.Console:
This interface is implemented on ``/org/qemu/Display1/Console_$id``. You
may discover available consoles through introspection or with the
:dbus:prop:`org.qemu.Display1.VM.ConsoleIDs` property.
A console is attached to a video device head. It may be "Graphic" or
"Text" (see :dbus:prop:`Type` and other properties).
Interactions with a console may be done with
:dbus:iface:`org.qemu.Display1.Keyboard`,
:dbus:iface:`org.qemu.Display1.Mouse` and
:dbus:iface:`org.qemu.Display1.MultiTouch` interfaces when available.
-->
<interface name="org.qemu.Display1.Console">
<!--
RegisterListener:
@listener: a Unix socket FD, for peer-to-peer D-Bus communication.
Register a console listener, which will receive display updates, until
it is disconnected.
Multiple listeners may be registered simultaneously.
The listener is expected to implement the
:dbus:iface:`org.qemu.Display1.Listener` interface.
-->
<method name="RegisterListener">
<arg type="h" name="listener" direction="in"/>
</method>
<!--
SetUIInfo:
@width_mm: the physical display width in millimeters.
@height_mm: the physical display height in millimeters.
@xoff: horizontal offset, in pixels.
@yoff: vertical offset, in pixels.
@width: console width, in pixels.
@height: console height, in pixels.
Modify the dimensions and display settings.
-->
<method name="SetUIInfo">
<arg name="width_mm" type="q" direction="in"/>
<arg name="height_mm" type="q" direction="in"/>
<arg name="xoff" type="i" direction="in"/>
<arg name="yoff" type="i" direction="in"/>
<arg name="width" type="u" direction="in"/>
<arg name="height" type="u" direction="in"/>
</method>
<!--
Label:
A user-friendly name for the console (for ex: "VGA").
-->
<property name="Label" type="s" access="read"/>
<!--
Head:
Graphical device head number.
-->
<property name="Head" type="u" access="read"/>
<!--
Type:
Console type ("Graphic" or "Text").
-->
<property name="Type" type="s" access="read"/>
<!--
Width:
Console width, in pixels.
-->
<property name="Width" type="u" access="read"/>
<!--
Height:
Console height, in pixels.
-->
<property name="Height" type="u" access="read"/>
<!--
DeviceAddress:
The device address (ex: "pci/0000/02.0").
-->
<property name="DeviceAddress" type="s" access="read"/>
<!--
Interfaces:
This property lists extra interfaces provided by the
``/org/qemu/Display1/Console_$id`` object, and can be used to detect the
capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.Keyboard:
This interface is optionally implemented on
``/org/qemu/Display1/Console_$id`` (see
:dbus:iface:`~org.qemu.Display1.Console`).
-->
<interface name="org.qemu.Display1.Keyboard">
<!--
Press:
@keycode: QEMU key number (xtkbd + special re-encoding of high bit)
Send a key press event.
-->
<method name="Press">
<arg type="u" name="keycode" direction="in"/>
</method>
<!--
Release:
@keycode: QEMU key number (xtkbd + special re-encoding of high bit)
Send a key release event.
-->
<method name="Release">
<arg type="u" name="keycode" direction="in"/>
</method>
<!--
Modifiers:
The active keyboard modifiers::
Scroll = 1<< 0
Num = 1<< 1
Caps = 1<< 2
-->
<property name="Modifiers" type="u" access="read"/>
</interface>
<!--
org.qemu.Display1.Mouse:
This interface is optionally implemented on
``/org/qemu/Display1/Console_$id`` (see
:dbus:iface:`~org.qemu.Display1.Console` documentation).
.. _dbus-button-values:
**Button values**::
Left = 0
Middle = 1
Right = 2
Wheel-up = 3
Wheel-down = 4
Side = 5
Extra = 6
-->
<interface name="org.qemu.Display1.Mouse">
<!--
Press:
@button: :ref:`button value<dbus-button-values>`.
Send a mouse button press event.
-->
<method name="Press">
<arg type="u" name="button" direction="in"/>
</method>
<!--
Release:
@button: :ref:`button value<dbus-button-values>`.
Send a mouse button release event.
-->
<method name="Release">
<arg type="u" name="button" direction="in"/>
</method>
<!--
SetAbsPosition:
@x: X position, in pixels.
@y: Y position, in pixels.
Set the mouse pointer position.
Returns an error if not :dbus:prop:`IsAbsolute`.
-->
<method name="SetAbsPosition">
<arg type="u" name="x" direction="in"/>
<arg type="u" name="y" direction="in"/>
</method>
<!--
RelMotion:
@dx: X-delta, in pixels.
@dy: Y-delta, in pixels.
Move the mouse pointer position, relative to the current position.
Returns an error if :dbus:prop:`IsAbsolute`.
-->
<method name="RelMotion">
<arg type="i" name="dx" direction="in"/>
<arg type="i" name="dy" direction="in"/>
</method>
<!--
IsAbsolute:
Whether the mouse is using absolute movements.
-->
<property name="IsAbsolute" type="b" access="read"/>
</interface>
<!--
org.qemu.Display1.MultiTouch:
This interface in implemented on ``/org/qemu/Display1/Console_$id`` (see
:dbus:iface:`~org.qemu.Display1.Console` documentation).
.. _dbus-kind-values:
**Kind values**::
Begin = 0
Update = 1
End = 2
Cancel = 3
-->
<interface name="org.qemu.Display1.MultiTouch">
<!--
SendEvent:
@kind: The touch event kind
@num_slot: The slot number.
@x: The x coordinates.
@y: The y coordinates.
Send a touch gesture event.
-->
<method name="SendEvent">
<arg type="u" name="kind" direction="in"/>
<arg type="t" name="num_slot" direction="in"/>
<arg type="d" name="x" direction="in"/>
<arg type="d" name="y" direction="in"/>
</method>
<!--
MaxSlots:
The maximum number of slots.
-->
<property name="MaxSlots" type="i" access="read"/>
</interface>
<!--
org.qemu.Display1.Listener:
This client-side interface must be available on
``/org/qemu/Display1/Listener`` when registering the peer-to-peer
connection with :dbus:meth:`~org.qemu.Display1.Console.Register`.
-->
<interface name="org.qemu.Display1.Listener">
<!--
Scanout:
@width: display width, in pixels.
@height: display height, in pixels.
@stride: data stride, in bytes.
@pixman_format: image format (ex: ``PIXMAN_X8R8G8B8``).
@data: image data.
Resize and update the display content.
The data to transfer for the display update may be large. The preferred
scanout method is :dbus:meth:`ScanoutDMABUF`, used whenever possible.
-->
<method name="Scanout">
<arg type="u" name="width" direction="in"/>
<arg type="u" name="height" direction="in"/>
<arg type="u" name="stride" direction="in"/>
<arg type="u" name="pixman_format" direction="in"/>
<arg type="ay" name="data" direction="in">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
Update:
@x: X update position, in pixels.
@y: Y update position, in pixels.
@width: update width, in pixels.
@height: update height, in pixels.
@stride: data stride, in bytes.
@pixman_format: image format (ex: ``PIXMAN_X8R8G8B8``).
@data: display image data.
Update the display content.
This method is only called after a :dbus:meth:`Scanout` call.
-->
<method name="Update">
<arg type="i" name="x" direction="in"/>
<arg type="i" name="y" direction="in"/>
<arg type="i" name="width" direction="in"/>
<arg type="i" name="height" direction="in"/>
<arg type="u" name="stride" direction="in"/>
<arg type="u" name="pixman_format" direction="in"/>
<arg type="ay" name="data" direction="in">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
ScanoutDMABUF:
@dmabuf: the DMABUF file descriptor.
@width: display width, in pixels.
@height: display height, in pixels.
@stride: stride, in bytes.
@fourcc: DMABUF fourcc.
@modifier: DMABUF modifier.
@y0_top: whether Y position 0 is the top or not.
Resize and update the display content with a DMABUF.
-->
<method name="ScanoutDMABUF">
<arg type="h" name="dmabuf" direction="in"/>
<arg type="u" name="width" direction="in"/>
<arg type="u" name="height" direction="in"/>
<arg type="u" name="stride" direction="in"/>
<arg type="u" name="fourcc" direction="in"/>
<!-- xywh? -->
<arg type="t" name="modifier" direction="in"/>
<arg type="b" name="y0_top" direction="in"/>
</method>
<!--
UpdateDMABUF:
@x: the X update position, in pixels.
@y: the Y update position, in pixels.
@width: the update width, in pixels.
@height: the update height, in pixels.
Update the display content with the current DMABUF and the given region.
-->
<method name="UpdateDMABUF">
<arg type="i" name="x" direction="in"/>
<arg type="i" name="y" direction="in"/>
<arg type="i" name="width" direction="in"/>
<arg type="i" name="height" direction="in"/>
</method>
<!--
Disable:
Disable the display (turn it off).
-->
<method name="Disable"/>
<!--
MouseSet:
@x: X mouse position, in pixels.
@y: Y mouse position, in pixels.
@on: whether the mouse is visible or not.
Set the mouse position and visibility.
-->
<method name="MouseSet">
<arg type="i" name="x" direction="in"/>
<arg type="i" name="y" direction="in"/>
<arg type="i" name="on" direction="in"/>
</method>
<!--
CursorDefine:
@width: cursor width, in pixels.
@height: cursor height, in pixels.
@hot_x: hot-spot X position, in pixels.
@hot_y: hot-spot Y position, in pixels.
@data: the cursor data.
Set the mouse cursor shape and hot-spot. The "data" must be ARGB, 32-bit
per pixel.
-->
<method name="CursorDefine">
<arg type="i" name="width" direction="in"/>
<arg type="i" name="height" direction="in"/>
<arg type="i" name="hot_x" direction="in"/>
<arg type="i" name="hot_y" direction="in"/>
<arg type="ay" name="data" direction="in">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
Interfaces:
This property lists extra interfaces provided by the
/org/qemu/Display1/Listener object, and can be used to detect
the capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.Listener.Unix.Map:
This optional client-side interface can complement
org.qemu.Display1.Listener on ``/org/qemu/Display1/Listener`` for
Unix-specific shared memory scanouts.
-->
<interface name="org.qemu.Display1.Listener.Unix.Map">
<!--
ScanoutMap:
@handle: the shared map FD.
@offset: mapping offset, in bytes.
@width: display width, in pixels.
@height: display height, in pixels.
@stride: stride, in bytes.
@pixman_format: image format (ex: ``PIXMAN_X8R8G8B8``).
Resize and update the display content with a shared map.
-->
<method name="ScanoutMap">
<arg type="h" name="handle" direction="in"/>
<arg type="u" name="offset" direction="in"/>
<arg type="u" name="width" direction="in"/>
<arg type="u" name="height" direction="in"/>
<arg type="u" name="stride" direction="in"/>
<arg type="u" name="pixman_format" direction="in"/>
</method>
<!--
UpdateMap:
@x: the X update position, in pixels.
@y: the Y update position, in pixels.
@width: the update width, in pixels.
@height: the update height, in pixels.
Update the display content with the current shared map and the given region.
-->
<method name="UpdateMap">
<arg type="i" name="x" direction="in"/>
<arg type="i" name="y" direction="in"/>
<arg type="i" name="width" direction="in"/>
<arg type="i" name="height" direction="in"/>
</method>
</interface>
<!--
org.qemu.Display1.Listener.Win32.Map:
This optional client-side interface can complement
org.qemu.Display1.Listener on ``/org/qemu/Display1/Listener`` for Windows
specific shared memory scanouts.
-->
<!--
org.qemu.Display1.Listener.Win32.D3d11:
This optional client-side interface can complement
org.qemu.Display1.Listener on ``/org/qemu/Display1/Listener`` for Windows
specific Direct3D texture sharing of the scanouts.
-->
<interface name="org.qemu.Display1.Listener.Win32.D3d11">
<!--
ScanoutTexture2d:
@handle: the NT handle for the shared texture (to be opened back with ID3D11Device1::OpenSharedResource1).
@texture_width: texture width, in pixels.
@texture_height: texture height, in pixels.
@y0_top: whether Y position 0 is the top or not.
@x: the X scanout position, in pixels.
@y: the Y scanout position, in pixels.
@width: the scanout width, in pixels.
@height: the scanout height, in pixels.
Resize and update the display content with a Direct3D 11 2D texture.
You must acquire and release the associated KeyedMutex 0 during rendering.
-->
<method name="ScanoutTexture2d">
<arg type="t" name="handle" direction="in"/>
<arg type="u" name="texture_width" direction="in"/>
<arg type="u" name="texture_height" direction="in"/>
<arg type="b" name="y0_top" direction="in"/>
<arg type="u" name="x" direction="in"/>
<arg type="u" name="y" direction="in"/>
<arg type="u" name="width" direction="in"/>
<arg type="u" name="height" direction="in"/>
</method>
<!--
UpdateTexture2d:
@x: the X update position, in pixels.
@y: the Y update position, in pixels.
@width: the update width, in pixels.
@height: the update height, in pixels.
Update the display content with the current Direct3D 2D texture and the given region.
You must acquire and release the associated KeyedMutex 0 during rendering.
-->
<method name="UpdateTexture2d">
<arg type="i" name="x" direction="in"/>
<arg type="i" name="y" direction="in"/>
<arg type="i" name="width" direction="in"/>
<arg type="i" name="height" direction="in"/>
</method>
</interface>
<!--
org.qemu.Display1.Listener.Unix.ScanoutDMABUF2:
This optional client-side interface can complement
org.qemu.Display1.Listener on ``/org/qemu/Display1/Listener`` for
Unix-specific DMABUF scanout setup which support multi plane.
-->
<interface name="org.qemu.Display1.Listener.Unix.ScanoutDMABUF2">
<!--
ScanoutDMABUF2:
@dmabuf: DMABUF file descriptor of each plane.
@x: display x offset, in pixels
@y: display y offset, in pixels
@width: display width, in pixels.
@height: display height, in pixels.
@offset: offset of each plane, in bytes.
@stride: stride of each plane, in bytes.
@num_planes: plane number.
@fourcc: DMABUF fourcc.
@backing_width: backing framebuffer width, in pixels
@backing_height: backing framebuffer height, in pixels
@modifier: DMABUF modifier.
@y0_top: whether Y position 0 is the top or not.
Resize and update the display content with DMABUF.
-->
<method name="ScanoutDMABUF2">
<arg type="ah" name="dmabuf" direction="in"/>
<arg type="u" name="x" direction="in"/>
<arg type="u" name="y" direction="in"/>
<arg type="u" name="width" direction="in"/>
<arg type="u" name="height" direction="in"/>
<arg type="au" name="offset" direction="in"/>
<arg type="au" name="stride" direction="in"/>
<arg type="u" name="num_planes" direction="in"/>
<arg type="u" name="fourcc" direction="in"/>
<arg type="u" name="backing_width" direction="in"/>
<arg type="u" name="backing_height" direction="in"/>
<arg type="t" name="modifier" direction="in"/>
<arg type="b" name="y0_top" direction="in"/>
</method>
</interface>
<!--
org.qemu.Display1.Clipboard:
This interface must be implemented by both the client and the server on
``/org/qemu/Display1/Clipboard`` to support clipboard sharing between
the client and the guest.
Once :dbus:meth:`Register`'ed, method calls may be sent and received in both
directions. Unregistered callers will get error replies.
.. _dbus-clipboard-selection:
**Selection values**::
Clipboard = 0
Primary = 1
Secondary = 2
.. _dbus-clipboard-serial:
**Serial counter**
To solve potential clipboard races, clipboard grabs have an associated
serial counter. It is set to 0 on registration, and incremented by 1 for
each grab. The peer with the highest serial is the clipboard grab owner.
When a grab with a lower serial is received, it should be discarded.
When a grab is attempted with the same serial number as the current grab,
the one coming from the client should have higher priority, and the client
should gain clipboard grab ownership.
-->
<interface name="org.qemu.Display1.Clipboard">
<!--
Register:
Register a clipboard session and reinitialize the serial counter.
The client must register itself, and is granted an exclusive
access for handling the clipboard.
The server can reinitialize the session as well (to reset the counter).
-->
<method name="Register"/>
<!--
Unregister:
Unregister the clipboard session.
-->
<method name="Unregister"/>
<!--
Grab:
@selection: a :ref:`selection value<dbus-clipboard-selection>`.
@serial: the current grab :ref:`serial<dbus-clipboard-serial>`.
@mimes: the list of available content MIME types.
Grab the clipboard, claiming current clipboard content.
-->
<method name="Grab">
<arg type="u" name="selection"/>
<arg type="u" name="serial"/>
<arg type="as" name="mimes"/>
</method>
<!--
Release:
@selection: a :ref:`selection value<dbus-clipboard-selection>`.
Release the clipboard (does nothing if not the current owner).
-->
<method name="Release">
<arg type="u" name="selection"/>
</method>
<!--
Request:
@selection: a :ref:`selection value<dbus-clipboard-selection>`
@mimes: requested MIME types (by order of preference).
@reply_mime: the returned data MIME type.
@data: the clipboard data.
Request the clipboard content.
Return an error if the clipboard is empty, or the requested MIME types
are unavailable.
-->
<method name="Request">
<arg type="u" name="selection"/>
<arg type="as" name="mimes"/>
<arg type="s" name="reply_mime" direction="out"/>
<arg type="ay" name="data" direction="out">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
Interfaces:
This property lists extra interfaces provided by the
/org/qemu/Display1/Clipboard object, and can be used to detect
the capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.Audio:
Audio backend may be available on ``/org/qemu/Display1/Audio``.
-->
<interface name="org.qemu.Display1.Audio">
<!--
RegisterOutListener:
@listener: a Unix socket FD, for peer-to-peer D-Bus communication.
Register an audio backend playback handler.
Multiple listeners may be registered simultaneously.
The listener is expected to implement the
:dbus:iface:`org.qemu.Display1.AudioOutListener` interface.
-->
<method name="RegisterOutListener">
<arg type="h" name="listener" direction="in"/>
</method>
<!--
RegisterInListener:
@listener: a Unix socket FD, for peer-to-peer D-Bus communication.
Register an audio backend record handler.
Multiple listeners may be registered simultaneously.
The listener is expected to implement the
:dbus:iface:`org.qemu.Display1.AudioInListener` interface.
-->
<method name="RegisterInListener">
<arg type="h" name="listener" direction="in"/>
</method>
<!--
NSamples:
The number of samples per read/write frames. (for example the default is
480, or 10ms at 48kHz)
(earlier version of the display interface do not provide this property)
-->
<property name="NSamples" type="u" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
</property>
<!--
Interfaces:
This property lists extra interfaces provided by the
/org/qemu/Display1/Audio object, and can be used to detect
the capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.AudioOutListener:
This client-side interface must be available on
``/org/qemu/Display1/AudioOutListener`` when registering the peer-to-peer
connection with :dbus:meth:`~org.qemu.Display1.Audio.RegisterOutListener`.
-->
<interface name="org.qemu.Display1.AudioOutListener">
<!--
Init:
@id: the stream ID.
@bits: PCM bits per sample.
@is_signed: whether the PCM data is signed.
@is_float: PCM floating point format.
@freq: the PCM frequency in Hz.
@nchannels: the number of channels.
@bytes_per_frame: the bytes per frame.
@bytes_per_second: the bytes per second.
@be: whether using big-endian format.
Initializes a PCM playback stream.
-->
<method name="Init">
<arg name="id" type="t" direction="in"/>
<arg name="bits" type="y" direction="in"/>
<arg name="is_signed" type="b" direction="in"/>
<arg name="is_float" type="b" direction="in"/>
<arg name="freq" type="u" direction="in"/>
<arg name="nchannels" type="y" direction="in"/>
<arg name="bytes_per_frame" type="u" direction="in"/>
<arg name="bytes_per_second" type="u" direction="in"/>
<arg name="be" type="b" direction="in"/>
</method>
<!--
Fini:
@id: the stream ID.
Finish & close a playback stream.
-->
<method name="Fini">
<arg name="id" type="t" direction="in"/>
</method>
<!--
SetEnabled:
@id: the stream ID.
Resume or suspend the playback stream.
-->
<method name="SetEnabled">
<arg name="id" type="t" direction="in"/>
<arg name="enabled" type="b" direction="in"/>
</method>
<!--
SetVolume:
@id: the stream ID.
@mute: whether the stream is muted.
@volume: the volume per-channel.
Set the stream volume and mute state (volume without unit, 0-255).
-->
<method name="SetVolume">
<arg name="id" type="t" direction="in"/>
<arg name="mute" type="b" direction="in"/>
<arg name="volume" type="ay" direction="in">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
Write:
@id: the stream ID.
@data: the PCM data.
PCM stream to play.
-->
<method name="Write">
<arg name="id" type="t" direction="in"/>
<arg type="ay" name="data" direction="in">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
Interfaces:
This property lists extra interfaces provided by the
/org/qemu/Display1/AudioOutListener object, and can be used to detect
the capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.AudioInListener:
This client-side interface must be available on
``/org/qemu/Display1/AudioInListener`` when registering the peer-to-peer
connection with :dbus:meth:`~org.qemu.Display1.Audio.RegisterInListener`.
-->
<interface name="org.qemu.Display1.AudioInListener">
<!--
Init:
@id: the stream ID.
@bits: PCM bits per sample.
@is_signed: whether the PCM data is signed.
@is_float: PCM floating point format.
@freq: the PCM frequency in Hz.
@nchannels: the number of channels.
@bytes_per_frame: the bytes per frame.
@bytes_per_second: the bytes per second.
@be: whether using big-endian format.
Initializes a PCM record stream.
-->
<method name="Init">
<arg name="id" type="t" direction="in"/>
<arg name="bits" type="y" direction="in"/>
<arg name="is_signed" type="b" direction="in"/>
<arg name="is_float" type="b" direction="in"/>
<arg name="freq" type="u" direction="in"/>
<arg name="nchannels" type="y" direction="in"/>
<arg name="bytes_per_frame" type="u" direction="in"/>
<arg name="bytes_per_second" type="u" direction="in"/>
<arg name="be" type="b" direction="in"/>
</method>
<!--
Fini:
@id: the stream ID.
Finish & close a record stream.
-->
<method name="Fini">
<arg name="id" type="t" direction="in"/>
</method>
<!--
SetEnabled:
@id: the stream ID.
Resume or suspend the record stream.
-->
<method name="SetEnabled">
<arg name="id" type="t" direction="in"/>
<arg name="enabled" type="b" direction="in"/>
</method>
<!--
SetVolume:
@id: the stream ID.
@mute: whether the stream is muted.
@volume: the volume per-channel.
Set the stream volume and mute state (volume without unit, 0-255).
-->
<method name="SetVolume">
<arg name="id" type="t" direction="in"/>
<arg name="mute" type="b" direction="in"/>
<arg name="volume" type="ay" direction="in">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
Read:
@id: the stream ID.
@size: the amount to read, in bytes.
@data: the recorded data (which may be less than requested).
Read "size" bytes from the record stream.
-->
<method name="Read">
<arg name="id" type="t" direction="in"/>
<arg name="size" type="t" direction="in"/>
<arg type="ay" name="data" direction="out">
<annotation name="org.gtk.GDBus.C.ForceGVariant" value="true"/>
</arg>
</method>
<!--
Interfaces:
This property lists extra interfaces provided by the
/org/qemu/Display1/AudioInListener object, and can be used to detect
the capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.Chardev:
Character devices may be available on ``/org/qemu/Display1/Chardev_$id``.
They may be used for different kind of streams, which are identified via
their FQDN :dbus:prop:`Name`.
.. _dbus-chardev-fqdn:
Here are some known reserved kind names (the ``org.qemu`` prefix is
reserved by QEMU):
org.qemu.console.serial.0
A serial console stream.
org.qemu.monitor.hmp.0
A QEMU HMP human monitor.
org.qemu.monitor.qmp.0
A QEMU QMP monitor.
org.qemu.usbredir
A usbredir stream.
-->
<interface name="org.qemu.Display1.Chardev">
<!--
Register:
@stream: a Unix FD to redirect the stream to.
Register a file-descriptor for the stream handling.
The current handler, if any, will be replaced.
-->
<method name="Register">
<arg type="h" name="stream" direction="in"/>
</method>
<!--
SendBreak:
Send a break event to the character device.
-->
<method name="SendBreak"/>
<!--
Name:
The FQDN name to identify the kind of stream. See :ref:`reserved
names<dbus-chardev-fqdn>`.
-->
<property name="Name" type="s" access="read"/>
<!--
FEOpened:
Whether the front-end side is opened.
-->
<property name="FEOpened" type="b" access="read"/>
<!--
Echo:
Whether the input should be echo'ed (for serial streams).
-->
<property name="Echo" type="b" access="read"/>
<!--
Owner:
The D-Bus unique name of the registered handler.
-->
<property name="Owner" type="s" access="read"/>
<!--
Interfaces:
This property lists extra interfaces provided by the
``/org/qemu/Display1/Chardev_$i`` object, and can be used to detect
the capabilities with which they are communicating.
Unlike the standard D-Bus Introspectable interface, querying this
property does not require parsing XML.
(earlier version of the display interface do not provide this property)
-->
<property name="Interfaces" type="as" access="read"/>
</interface>
<!--
org.qemu.Display1.Chardev.VCEncoding:
Provides encoding information for virtual console chardevs.
This interface is present on chardev objects that are virtual
consoles, and exposes the character encoding used by the guest.
-->
<interface name="org.qemu.Display1.Chardev.VCEncoding">
<!--
Encoding:
The character encoding used by the virtual console
(matching ``ChardevVCEncoding``): ``cp437`` or ``utf8``.
-->
<property name="Encoding" type="s" access="read"/>
</interface>
</node>
▸
third-party/qemu-dbus-display/README.md
+48
−0
@@ -1,0 +1,48 @@
# QEMU D-Bus display interface
`dbus-display1.xml` describes the `org.qemu.Display1.*` D-Bus interfaces that QEMU exports
when started with `-display dbus` and `-audiodev dbus`. Sunshine's `capture = qemu` backend
generates its GDBus bindings from this file at configure time.
## Provenance
| Item | Value |
|-----------------|------------------------------------------------------------------|
| Upstream | https://gitlab.com/qemu-project/qemu |
| Release | v11.1.1 (`qemu-11.1.1.tar.xz`) |
| Commit | `c3d48b7d1e89604920e5b81b91140c2ad39a1943` |
| Source file | `ui/dbus-display1.xml` |
| Source sha256 | `b611ea1e075e8380611437ba0ca65ee77b970c435530e349fe9b5ef1998ee335` |
| Vendored sha256 | `9531784ec43c168df8268bf31a70812f9ddb867f41acd2684fab7f51ff59e87e` |
## Why the file is preprocessed
The upstream XML contains `<?if $(env.HOST_OS) ...?>` processing instructions that
`gdbus-codegen` cannot parse. QEMU's build runs `scripts/xml-preprocess.py` first. The vendored
copy is the output of that same step for Linux hosts:
```bash
HOST_OS=linux python3 qemu-11.1.1/scripts/xml-preprocess.py \
qemu-11.1.1/ui/dbus-display1.xml \
third-party/qemu-dbus-display/dbus-display1.xml
```
The bindings are generated with the same flags QEMU uses (`ui/meson.build`):
```bash
gdbus-codegen --glib-min-required 2.64 --interface-prefix org.qemu. \
--c-namespace QemuDBus --generate-c-code dbus-display1 dbus-display1.xml
```
## Updating
Repeat the preprocessing command against a newer QEMU release, then update the table above. The
interfaces are versioned by name (`org.qemu.Display1`); new optional interfaces are advertised
through the `Interfaces` properties, so older QEMU releases keep working with newer bindings.
## License
`ui/dbus-display1.xml` carries no license header. Per QEMU's `LICENSE` file, source files with no
licensing information are released under the GNU General Public License, version 2 or (at your
option) any later version, which is compatible with Sunshine's GPL-3.0 license.
`scripts/xml-preprocess.py` is MIT licensed and is not vendored.