Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/streaming-host-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ features. `DeviceNode` paths can help diagnose host-side enumeration; client
feature support still depends on the physical controller, connection, client,
and game. See the [end-user compatibility matrix](end-user-gamepad-guide.md#compatibility-matrix)
for observed streaming behavior.

Route `RuntimeOptions::log_callback` into the host logger for lifecycle,
failure, and debug-level input coordinate diagnostics. When streaming one
monitor from a multi-monitor desktop, supply the full desktop bounds and the
selected viewport in `CreateMouseOptions`. See the
[mouse viewport setup](usage.md#absolute-mouse-viewports).
Use native screen units for that geometry: screen points on macOS and desktop
pixels on Windows, Linux, and FreeBSD. The captured image dimensions supplied
to `Mouse::move_absolute()` can differ from the viewport dimensions.
55 changes: 55 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,61 @@ The API also exposes `Keyboard`, `Mouse`, `Touchscreen`, `Trackpad`, and
`PenTablet` where supported. `DeviceNode` provides paths for diagnostics or
handoff to another local input consumer.
## Diagnostics
Hosts can route libvirtualhid diagnostics into their own logging system by
installing `RuntimeOptions::log_callback` before creating the runtime:
```cpp
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
runtime_options.log_callback = [](lvh::LogLevel level, const std::string &message) {
host_log(level, message);
};
auto runtime = lvh::Runtime::create(runtime_options);
```

The callback receives runtime and device lifecycle messages, operation failures,
and debug-level mouse coordinate diagnostics. It runs synchronously on the
calling thread. If a consumer callback throws, libvirtualhid disables it for
subsequent messages so it cannot interrupt input delivery.

## Absolute Mouse Viewports

Absolute mouse coordinates can target one monitor inside a larger virtual
desktop. Supply both the desktop bounds and the selected viewport in native
screen coordinates when creating the mouse: screen points on macOS, and desktop
pixels on Windows, Linux, and FreeBSD. Offsets and dimensions must use the same
units.

```cpp
lvh::CreateMouseOptions mouse_options;
mouse_options.profile = lvh::profiles::mouse();
mouse_options.desktop = {.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 1080};
mouse_options.viewport = {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080};
auto created = runtime->create_mouse(mouse_options);
```
On macOS, use the global screen-point geometry reported by `CGDisplayBounds`
for the desktop and viewport. A Retina display with 3840×2160 backing pixels
and 1920×1080 screen points needs a 1920×1080 viewport. Absolute source
coordinates may still use the 3840×2160 captured image dimensions; the backend
maps that image's center to (960, 540) screen points. See Apple's
[coordinate guidance](https://developer.apple.com/library/archive/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/APIs/APIs.html).
`Mouse::move_absolute()` coordinates are scaled from their supplied source
dimensions into the target viewport, then normalized against the virtual
desktop where the platform input API requires it. This contract covers
CoreGraphics on macOS, `SendInput` on Windows, and the XTest or `uinput` path on
Linux and FreeBSD, including virtual desktops whose origin is negative. Leave
both viewport dimensions at zero to retain the platform-default pointer area
(the main display on macOS and the virtual desktop on other current backends).
The macOS default refreshes on every mouse submission that uses a location,
including after display resolution or main-display changes. If its bounds are
temporarily unavailable, the submission fails and a later submission retries.
A configured target viewport must be fully contained by its desktop and remains
fixed for the mouse's lifetime; recreate the mouse when that geometry changes.
## License and diagnostic tool
On Windows and macOS, `get_license_status`, `activate_license`,
Expand Down
169 changes: 162 additions & 7 deletions src/core/runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@

// standard includes
#include <algorithm>
#include <atomic>
#include <cmath>
#include <format>
#include <memory>
#include <mutex>
#include <sstream>
#include <utility>

// local includes
Expand All @@ -19,6 +22,51 @@

namespace lvh::detail {

/**
* @brief Exception-safe dispatcher for a consumer diagnostic callback.
*/
class Logger {
public:
/**
* @brief Construct a dispatcher for an optional callback.
*
* @param callback Consumer diagnostic callback.
*/
explicit Logger(LogCallback callback):
callback_ {std::move(callback)} {}

/**
* @brief Report whether a callback is installed.
*
* @return `true` when messages have a destination.
*/
bool enabled() const noexcept {
return static_cast<bool>(callback_) && !callback_failed_.load(std::memory_order_relaxed);
}

/**
* @brief Emit one diagnostic message without allowing consumer exceptions to escape.
*
* @param level Message severity.
* @param message Diagnostic text.
*/
void emit(LogLevel level, const std::string &message) const noexcept {
if (!enabled()) {
return;
}

try {
callback_(level, message);
} catch (...) {
callback_failed_.store(true, std::memory_order_relaxed);
}
}

private:
LogCallback callback_;
mutable std::atomic_bool callback_failed_ = false; ///< Whether the consumer callback threw an exception.
};

class SynchronizedState {
public:
template<class Func>
Expand Down Expand Up @@ -71,14 +119,21 @@ namespace lvh::detail {
};

struct MouseDevice: SynchronizedState {
explicit MouseDevice(DeviceId device_id, CreateMouseOptions create_options, std::unique_ptr<BackendMouse> backend_mouse):
explicit MouseDevice(
DeviceId device_id,
CreateMouseOptions create_options,
std::unique_ptr<BackendMouse> backend_mouse,
std::shared_ptr<Logger> device_logger
):
id {device_id},
options {std::move(create_options)},
backend {std::move(backend_mouse)} {}
backend {std::move(backend_mouse)},
logger {std::move(device_logger)} {}

DeviceId id;
CreateMouseOptions options;
std::unique_ptr<BackendMouse> backend;
std::shared_ptr<Logger> logger;
bool open = true;
MouseEvent last_event;
std::size_t submitted_events = 0;
Expand Down Expand Up @@ -141,11 +196,15 @@ namespace lvh::detail {
class RuntimeState: public SynchronizedState {
public:
explicit RuntimeState(RuntimeOptions runtime_options):
options {runtime_options},
backend {create_backend(runtime_options.backend)},
caps {backend->capabilities()} {}
options {std::move(runtime_options)},
logger {std::make_shared<Logger>(options.log_callback)},
backend {create_backend(options.backend)},
caps {backend->capabilities()} {
logger->emit(LogLevel::info, "initialized " + caps.backend_name + " backend");
}

RuntimeOptions options;
std::shared_ptr<Logger> logger;
std::unique_ptr<Backend> backend;
BackendCapabilities caps;
DeviceId next_device_id = 1;
Expand Down Expand Up @@ -199,10 +258,91 @@ namespace lvh {
if (options.profile.name.empty()) {
return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty");
}
if (const auto valid_dimensions = [](const PointerViewport &viewport) {
return viewport.width >= 0 && viewport.height >= 0 &&
((viewport.width == 0 && viewport.height == 0) || (viewport.width > 0 && viewport.height > 0));
};
!valid_dimensions(options.desktop) || !valid_dimensions(options.viewport)) {
return OperationStatus::failure(
ErrorCode::invalid_argument,
"mouse viewport dimensions must both be positive or both be zero"
);
}
const auto has_desktop = options.desktop.width > 0;
if (const auto has_viewport = options.viewport.width > 0; has_desktop != has_viewport) {
return OperationStatus::failure(
ErrorCode::invalid_argument,
"mouse desktop and target viewport must be configured together"
);
}
if (has_desktop) {
const auto desktop_right = static_cast<std::int64_t>(options.desktop.offset_x) + options.desktop.width;
const auto desktop_bottom = static_cast<std::int64_t>(options.desktop.offset_y) + options.desktop.height;
const auto viewport_right = static_cast<std::int64_t>(options.viewport.offset_x) + options.viewport.width;
const auto viewport_bottom = static_cast<std::int64_t>(options.viewport.offset_y) + options.viewport.height;
if (options.viewport.offset_x < options.desktop.offset_x || options.viewport.offset_y < options.desktop.offset_y || viewport_right > desktop_right || viewport_bottom > desktop_bottom) {
return OperationStatus::failure(
ErrorCode::invalid_argument,
"mouse target viewport must be contained by the virtual desktop"
);
}
}

return OperationStatus::success();
}

/**
* @brief Format a mouse event for the consumer diagnostic callback.
*
* @param id Runtime mouse identifier.
* @param event Mouse event being submitted.
* @param desktop Configured virtual desktop bounds.
* @param viewport Configured target viewport.
* @return Human-readable diagnostic message.
*/
std::string mouse_event_description(
DeviceId id,
const MouseEvent &event,
const PointerViewport &desktop,
const PointerViewport &viewport
) {
std::ostringstream message;
message << "mouse " << id << ' ';
switch (event.kind) {
using enum MouseEventKind;

case relative_motion:
message << "relative motion x=" << event.x << " y=" << event.y;
break;
case absolute_motion:
message << "absolute motion x=";
if (event.has_fractional_absolute_coordinates) {
message << event.absolute_x << " y=" << event.absolute_y;
} else {
message << event.x << " y=" << event.y;
}
message << " source=" << event.width << 'x' << event.height;
if (viewport.width > 0 && viewport.height > 0) {
message << " viewport=" << viewport.offset_x << ',' << viewport.offset_y << ' '
<< viewport.width << 'x' << viewport.height << " desktop=" << desktop.offset_x << ','
<< desktop.offset_y << ' ' << desktop.width << 'x' << desktop.height;
} else {
message << " viewport=platform-default";
}
break;
case button:
message << "button " << static_cast<int>(std::to_underlying(event.button)) << (event.pressed ? " pressed" : " released");
break;
case vertical_scroll:
message << "vertical scroll distance=" << event.high_resolution_scroll;
break;
case horizontal_scroll:
message << "horizontal scroll distance=" << event.high_resolution_scroll;
break;
}
return message.str();
}

OperationStatus validate_touchscreen_options(const CreateTouchscreenOptions &options) {
if (options.profile.device_type != DeviceType::touchscreen) {
return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a touchscreen");
Expand Down Expand Up @@ -612,10 +752,18 @@ namespace lvh {

OperationStatus Mouse::submit(const MouseEvent &event) {
if (const auto validation = validate_mouse_event(event); !validation.ok()) {
device_->logger->emit(LogLevel::warning, "rejected mouse event: " + validation.message());
return validation;
}

return with_device(device_, [&event](auto &device) {
if (device_->logger->enabled()) {
device_->logger->emit(
LogLevel::debug,
mouse_event_description(device_->id, event, device_->options.desktop, device_->options.viewport)
);
}

const auto status = with_device(device_, [&event](auto &device) {
if (!device.open) {
return OperationStatus::failure(ErrorCode::device_closed, "mouse is closed");
}
Expand All @@ -630,6 +778,10 @@ namespace lvh {
++device.submitted_events;
return OperationStatus::success();
});
if (!status.ok()) {
device_->logger->emit(LogLevel::error, "mouse input failed: " + status.message());
}
return status;
}

OperationStatus Mouse::move_relative(std::int32_t delta_x, std::int32_t delta_y) {
Expand Down Expand Up @@ -1129,6 +1281,7 @@ namespace lvh {

MouseCreationResult Runtime::create_mouse(const CreateMouseOptions &options) {
if (const auto validation = validate_mouse_options(options); !validation.ok()) {
state_->logger->emit(LogLevel::warning, "rejected mouse creation: " + validation.message());
return {validation, nullptr};
}

Expand All @@ -1138,15 +1291,17 @@ namespace lvh {

auto backend_result = state_->backend->create_mouse(id, options);
if (!backend_result) {
state_->logger->emit(LogLevel::error, "mouse creation failed: " + backend_result.status.message());
return {std::move(backend_result.status), nullptr};
}

auto device = std::make_shared<detail::MouseDevice>(id, options, std::move(backend_result.mouse));
auto device = std::make_shared<detail::MouseDevice>(id, options, std::move(backend_result.mouse), state_->logger);
state_->with_lock([this, &device]() {
state_->mice.emplace_back(device);
});

auto mouse = std::make_unique<Mouse>(detail::RuntimeConstructionToken {}, std::move(device));
state_->logger->emit(LogLevel::info, std::format("created mouse {}", id));
return {OperationStatus::success(), std::move(mouse)};
}

Expand Down
Loading
Loading