diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d81ae71..5b4e802 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,9 +5,7 @@ add_subdirectory(common) if(WIN32) add_subdirectory(windows) elseif(APPLE) - add_library(libdisplaydevice_macos_dummy INTERFACE) - add_library(libdisplaydevice::platform ALIAS libdisplaydevice_macos_dummy) - message(WARNING "MacOS is not supported yet.") + add_subdirectory(macos) elseif(UNIX) add_library(libdisplaydevice_linux_dummy INTERFACE) add_library(libdisplaydevice::platform ALIAS libdisplaydevice_linux_dummy) diff --git a/src/common/include/display_device/detail/persistent_state_utils.h b/src/common/include/display_device/detail/persistent_state_utils.h new file mode 100644 index 0000000..2ed4773 --- /dev/null +++ b/src/common/include/display_device/detail/persistent_state_utils.h @@ -0,0 +1,66 @@ +/** + * @file src/common/include/display_device/detail/persistent_state_utils.h + * @brief Shared helpers for persistent state wrappers. + */ +#pragma once + +// system includes +#include +#include +#include +#include +#include + +// local includes +#include "display_device/logging.h" +#include "display_device/settings_persistence_interface.h" + +namespace display_device::detail { + /** + * @brief Persist state and update the cached copy after a successful write. + * @tparam State Cached state type. + * @tparam SerializeFn Callable type used to serialize the state. + * @param settings_persistence_api Persistence API used to store or clear state. + * @param cached_state Cached state to compare and update. + * @param state New state to persist. + * @param serialize_state Callable that serializes a state and updates a success flag. + * @param serialize_error_message Error message used when serialization fails. + * @return True if the state was already current or was persisted successfully, false otherwise. + */ + template + [[nodiscard]] bool persistState( + SettingsPersistenceInterface &settings_persistence_api, + std::optional &cached_state, + const std::optional &state, + const SerializeFn &serialize_state, + const std::string_view serialize_error_message + ) { + if (cached_state == state) { + return true; + } + + if (!state) { + if (!settings_persistence_api.clear()) { + return false; + } + + cached_state = std::nullopt; + return true; + } + + bool success {false}; + const auto serialized_state {serialize_state(*state, success)}; + if (!success) { + DD_LOG(error) << serialize_error_message << "\n" + << serialized_state; + return false; + } + + if (!settings_persistence_api.store({std::begin(serialized_state), std::end(serialized_state)})) { + return false; + } + + cached_state = *state; + return true; + } +} // namespace display_device::detail diff --git a/src/common/include/display_device/detail/settings_state_utils.h b/src/common/include/display_device/detail/settings_state_utils.h new file mode 100644 index 0000000..e9abb51 --- /dev/null +++ b/src/common/include/display_device/detail/settings_state_utils.h @@ -0,0 +1,108 @@ +/** + * @file src/common/include/display_device/detail/settings_state_utils.h + * @brief Shared helpers for adapting settings state. + */ +#pragma once + +// system includes +#include +#include +#include +#include +#include +#include + +// local includes +#include "display_device/json.h" +#include "display_device/logging.h" +#include "display_device/types.h" + +namespace display_device::detail { + /** + * @brief Log messages used while stripping unavailable devices from initial state. + */ + struct InitialStateStripMessages { + std::string_view m_missing_topology; ///< Error logged when no initial topology devices remain. + std::string_view m_missing_primary; ///< Error logged when no usable primary devices remain. + std::string_view m_adapted_state; ///< Warning prefix logged when the initial state is adapted. + }; + + /** + * @brief Strip unavailable device ids from a topology. + * @tparam Topology Topology container type. + * @param topology Topology to strip. + * @param available_device_ids Device ids currently available. + * @return Topology containing only available device ids. + */ + template + [[nodiscard]] Topology stripUnavailableTopology(const Topology &topology, const StringSet &available_device_ids) { + Topology stripped_topology; + for (const auto &group : topology) { + std::vector stripped_group; + for (const auto &device_id : group) { + if (available_device_ids.contains(device_id)) { + stripped_group.push_back(device_id); + } + } + + if (!stripped_group.empty()) { + stripped_topology.push_back(stripped_group); + } + } + + return stripped_topology; + } + + /** + * @brief Strip unavailable devices from an initial settings state. + * @tparam Initial Initial state type. + * @tparam FormatTopologyFn Callable type used to format topology values for logs. + * @param initial_state Initial state to strip. + * @param available_device_ids Device ids currently available. + * @param primary_device_ids Current primary device ids. + * @param messages Log messages to use for failure and adaptation cases. + * @param format_topology Callable used to format topology values. + * @return Stripped initial state, or empty optional if no usable state remains. + */ + template + [[nodiscard]] std::optional stripInitialState( + const Initial &initial_state, + const StringSet &available_device_ids, + const StringSet &primary_device_ids, + const InitialStateStripMessages &messages, + const FormatTopologyFn &format_topology + ) { + const auto stripped_initial_topology {stripUnavailableTopology(initial_state.m_topology, available_device_ids)}; + + StringSet initial_primary_devices; + std::ranges::set_intersection( + initial_state.m_primary_devices, + available_device_ids, + std::inserter(initial_primary_devices, std::begin(initial_primary_devices)) + ); + + if (stripped_initial_topology.empty()) { + DD_LOG(error) << messages.m_missing_topology; + return std::nullopt; + } + + if (initial_primary_devices.empty()) { + initial_primary_devices = primary_device_ids; + if (initial_primary_devices.empty()) { + DD_LOG(error) << messages.m_missing_primary; + return std::nullopt; + } + } + + if (initial_state.m_topology != stripped_initial_topology || initial_state.m_primary_devices != initial_primary_devices) { + DD_LOG(warning) << messages.m_adapted_state << "\n" + << " - topology: " << format_topology(initial_state.m_topology) << " -> " << format_topology(stripped_initial_topology) << "\n" + << " - primary devices: " << toJson(initial_state.m_primary_devices, JSON_COMPACT) << " -> " << toJson(initial_primary_devices, JSON_COMPACT); + } + + return Initial { + stripped_initial_topology, + initial_primary_devices + }; + } +} // namespace display_device::detail diff --git a/src/common/include/display_device/display_power_interface.h b/src/common/include/display_device/display_power_interface.h new file mode 100644 index 0000000..5d665c2 --- /dev/null +++ b/src/common/include/display_device/display_power_interface.h @@ -0,0 +1,66 @@ +/** + * @file src/common/include/display_device/display_power_interface.h + * @brief Declarations for display power management interfaces. + */ +#pragma once + +// system includes +#include +#include +#include + +namespace display_device { + /** + * @brief Scoped guard that keeps a display awake while it is alive. + * + * The guard owns the platform power assertion. Destroying the guard releases + * that assertion. Some platforms attach the assertion to the current thread, + * so callers should destroy the guard on the same thread that created it when + * possible. + */ + class DisplayPowerGuardInterface { + public: + /** + * @brief Default virtual destructor. + */ + virtual ~DisplayPowerGuardInterface() = default; + }; + + /** + * @brief Cross-platform API for display wake and display-sleep prevention. + * + * This API prepares displays for capture. It does not change display + * topology, primary display selection, resolution, refresh rate, HDR state, + * or any persisted display settings. + */ + class DisplayPowerInterface { + public: + /** + * @brief Default virtual destructor. + */ + virtual ~DisplayPowerInterface() = default; + + /** + * @brief Ask the platform to wake a display before detection or capture. + * + * A successful result means the platform accepted the wake request and any + * platform-specific detection that this implementation can perform has + * passed. Some platforms cannot verify that the requested capture selector + * is awake, so callers should still retry their own capture-target + * enumeration after this method succeeds. + * + * @param display_name Platform capture selector returned by SettingsManagerInterface::getDisplayName. + * @param timeout Maximum time to wait before the caller retries capture-target detection. + * @returns True if the wake request succeeded and platform-specific detection passed, false otherwise. + */ + [[nodiscard]] virtual bool wakeDisplay(const std::string &display_name, std::chrono::milliseconds timeout) = 0; + + /** + * @brief Keep displays awake until the returned guard is destroyed. + * + * @param reason Short human-readable reason for the platform power assertion. + * @returns A guard that owns the assertion, or nullptr if the assertion could not be created. + */ + [[nodiscard]] virtual std::unique_ptr keepDisplayAwake(const std::string &reason) = 0; + }; +} // namespace display_device diff --git a/src/common/include/display_device/settings_manager_interface.h b/src/common/include/display_device/settings_manager_interface.h index 2142e7b..a8dfe3d 100644 --- a/src/common/include/display_device/settings_manager_interface.h +++ b/src/common/include/display_device/settings_manager_interface.h @@ -57,9 +57,9 @@ namespace display_device { [[nodiscard]] virtual EnumeratedDeviceList enumAvailableDevices() const = 0; /** - * @brief Get display name associated with the device. + * @brief Get the platform-specific display name associated with the device. * @param device_id A device to get display name for. - * @returns A display name for the device, or an empty string if the device is inactive or not found. + * @returns A display name or capture selector for the device, or an empty string if the device is inactive or not found. * Empty string can also be returned if an error has occurred. * @examples * const std::string device_id { "MY_DEVICE_ID" }; diff --git a/src/common/include/display_device/types.h b/src/common/include/display_device/types.h index bd5e542..dd49d4a 100644 --- a/src/common/include/display_device/types.h +++ b/src/common/include/display_device/types.h @@ -191,7 +191,7 @@ namespace display_device { }; std::string m_device_id {}; ///< A unique device ID used by this API to identify the device. - std::string m_display_name {}; ///< A logical name representing given by the OS for a display. + std::string m_display_name {}; ///< Platform-specific display name or capture selector for the device. std::string m_friendly_name {}; ///< A human-readable name for the device. std::optional m_edid {}; ///< Some basic parsed EDID data. std::optional m_info {}; ///< Additional information about an active display device. diff --git a/src/macos/CMakeLists.txt b/src/macos/CMakeLists.txt new file mode 100644 index 0000000..6f8ebbc --- /dev/null +++ b/src/macos/CMakeLists.txt @@ -0,0 +1,26 @@ +# A global identifier for the library +set(MODULE libdisplaydevice_macos) +set(MODULE_ALIAS libdisplaydevice::platform) + +# Globing headers (so that they appear in some IDEs) and sources +file(GLOB HEADER_LIST CONFIGURE_DEPENDS "include/display_device/macos/*.h") +file(GLOB HEADER_DETAIL_LIST CONFIGURE_DEPENDS "include/display_device/macos/detail/*.h") +file(GLOB SOURCE_LIST CONFIGURE_DEPENDS "*.cpp") + +# Automatic library - will be static or dynamic based on user setting +add_library(${MODULE} ${HEADER_LIST} ${HEADER_DETAIL_LIST} ${SOURCE_LIST}) +add_library(${MODULE_ALIAS} ALIAS ${MODULE}) + +# Provide the includes together with this library +target_include_directories(${MODULE} PUBLIC include) + +# Additional external libraries +include(Json_DD) + +# Link the additional libraries +target_link_libraries(${MODULE} PRIVATE + libdisplaydevice::common + nlohmann_json::nlohmann_json + "-framework CoreFoundation" + "-framework CoreGraphics" + "-framework IOKit") diff --git a/src/macos/display_power.cpp b/src/macos/display_power.cpp new file mode 100644 index 0000000..7ce39eb --- /dev/null +++ b/src/macos/display_power.cpp @@ -0,0 +1,137 @@ +/** + * @file src/macos/display_power.cpp + * @brief Definitions for macOS display power management. + */ +// class header include +#include "display_device/macos/display_power.h" + +// system includes +#include +#include +#include +#include +#include +#include +#include + +namespace display_device { + namespace { + using namespace std::chrono_literals; + + /** + * @brief Default reason used for short display wake assertions. + */ + constexpr std::string_view DISPLAY_DETECTION_REASON {"libdisplaydevice display detection"}; + + /** + * @brief Retry interval for waiting until a display becomes active. + */ + constexpr auto DISPLAY_WAKE_RETRY_INTERVAL {100ms}; + + /** + * @brief Parse a CoreGraphics display id from a capture selector. + * @param display_name Platform capture selector. + * @return Parsed display id, or empty optional if the selector is not numeric. + */ + [[nodiscard]] std::optional parseDisplayId(const std::string_view display_name) { + if (display_name.empty()) { + return std::nullopt; + } + + MacDisplayId display_id {}; + const auto *const begin {display_name.data()}; + const auto *const end {display_name.data() + display_name.size()}; + const auto [ptr, ec] {std::from_chars(begin, end, display_id)}; + if (ec != std::errc {} || ptr != end) { + return std::nullopt; + } + + return display_id; + } + + /** + * @brief Scoped macOS power assertion. + */ + class MacPowerAssertionGuard: public DisplayPowerGuardInterface { + public: + /** + * @brief Constructor. + * @param m_api macOS API layer. + * @param assertion_id Power assertion id to release. + */ + MacPowerAssertionGuard(std::shared_ptr m_api, const MacPowerAssertionId assertion_id): + m_m_api {std::move(m_api)}, + m_assertion_id {assertion_id} {} + + MacPowerAssertionGuard(const MacPowerAssertionGuard &) = delete; ///< Copy constructor. + MacPowerAssertionGuard &operator=(const MacPowerAssertionGuard &) = delete; ///< Copy assignment operator. + MacPowerAssertionGuard(MacPowerAssertionGuard &&) = delete; ///< Move constructor. + MacPowerAssertionGuard &operator=(MacPowerAssertionGuard &&) = delete; ///< Move assignment operator. + + /** + * @brief Destructor. + */ + ~MacPowerAssertionGuard() override { + static_cast(m_m_api->releasePowerAssertion(m_assertion_id)); + } + + private: + std::shared_ptr m_m_api; ///< macOS API layer. + MacPowerAssertionId m_assertion_id {}; ///< Power assertion id to release. + }; + } // namespace + + MacDisplayPower::MacDisplayPower(std::shared_ptr m_api): + m_m_api {std::move(m_api)} { + if (!m_m_api) { + throw std::invalid_argument {"Nullptr provided for MacApiLayerInterface in MacDisplayPower!"}; + } + } + + bool MacDisplayPower::wakeDisplay(const std::string &display_name, const std::chrono::milliseconds timeout) { + if (hasRequiredActiveDisplay(display_name)) { + return true; + } + + const auto assertion_id {m_m_api->declareUserActivity(std::string {DISPLAY_DETECTION_REASON})}; + if (!assertion_id.has_value()) { + return false; + } + + const auto assertion_guard {std::make_unique(m_m_api, *assertion_id)}; + const auto deadline {std::chrono::steady_clock::now() + timeout}; + + do { + if (hasRequiredActiveDisplay(display_name)) { + return true; + } + + const auto now {std::chrono::steady_clock::now()}; + if (now >= deadline) { + break; + } + + std::this_thread::sleep_for(std::min(DISPLAY_WAKE_RETRY_INTERVAL, std::chrono::duration_cast(deadline - now))); + } while (true); + + return hasRequiredActiveDisplay(display_name); + } + + std::unique_ptr MacDisplayPower::keepDisplayAwake(const std::string &reason) { + const auto assertion_id {m_m_api->createDisplaySleepAssertion(reason)}; + if (!assertion_id.has_value()) { + return nullptr; + } + + return std::make_unique(m_m_api, *assertion_id); + } + + bool MacDisplayPower::hasRequiredActiveDisplay(const std::string &display_name) const { + const auto active_displays {m_m_api->getDisplayIds(MacQueryType::Active)}; + if (const auto display_id {parseDisplayId(display_name)}; display_id.has_value()) { + return std::ranges::find(active_displays, *display_id) != std::end(active_displays); + } + + return !active_displays.empty(); + } +} // namespace display_device diff --git a/src/macos/include/display_device/macos/detail/json_serializer.h b/src/macos/include/display_device/macos/detail/json_serializer.h new file mode 100644 index 0000000..a6447b8 --- /dev/null +++ b/src/macos/include/display_device/macos/detail/json_serializer.h @@ -0,0 +1,18 @@ +/** + * @file src/macos/include/display_device/macos/detail/json_serializer.h + * @brief Declarations for private JSON serialization helpers (macOS-only). + */ +#pragma once + +// local includes +#include "display_device/detail/json_serializer.h" + +#ifdef DD_JSON_DETAIL +namespace display_device { + // Structs + DD_JSON_DECLARE_SERIALIZE_TYPE(MacDisplayMode) + DD_JSON_DECLARE_SERIALIZE_TYPE(MacSingleDisplayConfigState::Initial) + DD_JSON_DECLARE_SERIALIZE_TYPE(MacSingleDisplayConfigState::Modified) + DD_JSON_DECLARE_SERIALIZE_TYPE(MacSingleDisplayConfigState) +} // namespace display_device +#endif diff --git a/src/macos/include/display_device/macos/display_power.h b/src/macos/include/display_device/macos/display_power.h new file mode 100644 index 0000000..86753f5 --- /dev/null +++ b/src/macos/include/display_device/macos/display_power.h @@ -0,0 +1,46 @@ +/** + * @file src/macos/include/display_device/macos/display_power.h + * @brief Declarations for macOS display power management. + */ +#pragma once + +// system includes +#include + +// local includes +#include "display_device/display_power_interface.h" +#include "mac_api_layer_interface.h" + +namespace display_device { + /** + * @brief macOS implementation of DisplayPowerInterface. + */ + class MacDisplayPower: public DisplayPowerInterface { + public: + /** + * @brief Default constructor for the class. + * @param m_api A pointer to the macOS API layer. Will throw on nullptr. + */ + explicit MacDisplayPower(std::shared_ptr m_api); + + /** + * @copydoc DisplayPowerInterface::wakeDisplay + */ + [[nodiscard]] bool wakeDisplay(const std::string &display_name, std::chrono::milliseconds timeout) override; + + /** + * @copydoc DisplayPowerInterface::keepDisplayAwake + */ + [[nodiscard]] std::unique_ptr keepDisplayAwake(const std::string &reason) override; + + private: + /** + * @brief Check if the requested display is already active. + * @param display_name Platform capture selector to check. + * @returns True if the requested display is active, false otherwise. + */ + [[nodiscard]] bool hasRequiredActiveDisplay(const std::string &display_name) const; + + std::shared_ptr m_m_api; ///< macOS API layer. + }; +} // namespace display_device diff --git a/src/macos/include/display_device/macos/json.h b/src/macos/include/display_device/macos/json.h new file mode 100644 index 0000000..fbcf475 --- /dev/null +++ b/src/macos/include/display_device/macos/json.h @@ -0,0 +1,16 @@ +/** + * @file src/macos/include/display_device/macos/json.h + * @brief Declarations for JSON conversion functions (macOS-only). + */ +#pragma once + +// local includes +#include "display_device/json.h" +#include "types.h" + +namespace display_device { + DD_JSON_DECLARE_CONVERTER(MacActiveTopology) + DD_JSON_DECLARE_CONVERTER(MacDeviceDisplayModeMap) + DD_JSON_DECLARE_CONVERTER(MacHdrStateMap) + DD_JSON_DECLARE_CONVERTER(MacSingleDisplayConfigState) +} // namespace display_device diff --git a/src/macos/include/display_device/macos/mac_api_layer.h b/src/macos/include/display_device/macos/mac_api_layer.h new file mode 100644 index 0000000..60fbbc4 --- /dev/null +++ b/src/macos/include/display_device/macos/mac_api_layer.h @@ -0,0 +1,121 @@ +/** + * @file src/macos/include/display_device/macos/mac_api_layer.h + * @brief Declarations for the MacApiLayer. + */ +#pragma once + +// local includes +#include "mac_api_layer_interface.h" + +namespace display_device { + /** + * @brief Default implementation for the MacApiLayerInterface. + */ + class MacApiLayer: public MacApiLayerInterface { + public: + /** + * @copydoc MacApiLayerInterface::isApiAccessAvailable + */ + [[nodiscard]] bool isApiAccessAvailable() const override; + + /** + * @copydoc MacApiLayerInterface::getErrorString + */ + [[nodiscard]] std::string getErrorString(MacApiError error_code) const override; + + /** + * @copydoc MacApiLayerInterface::getDisplayIds + */ + [[nodiscard]] MacDisplayIdList getDisplayIds(MacQueryType type) const override; + + /** + * @copydoc MacApiLayerInterface::declareUserActivity + */ + [[nodiscard]] std::optional declareUserActivity(const std::string &reason) override; + + /** + * @copydoc MacApiLayerInterface::createDisplaySleepAssertion + */ + [[nodiscard]] std::optional createDisplaySleepAssertion(const std::string &reason) override; + + /** + * @copydoc MacApiLayerInterface::releasePowerAssertion + */ + [[nodiscard]] bool releasePowerAssertion(MacPowerAssertionId assertion_id) override; + + /** + * @copydoc MacApiLayerInterface::getDeviceId + */ + [[nodiscard]] std::string getDeviceId(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getCurrentDisplayMode + */ + [[nodiscard]] std::optional getCurrentDisplayMode(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getDisplayModes + */ + [[nodiscard]] MacDisplayModeList getDisplayModes(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getDisplayName + */ + [[nodiscard]] std::string getDisplayName(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getFriendlyName + */ + [[nodiscard]] std::string getFriendlyName(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getEdid + */ + [[nodiscard]] std::vector getEdid(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getDisplayScale + */ + [[nodiscard]] std::optional getDisplayScale(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getOriginPoint + */ + [[nodiscard]] std::optional getOriginPoint(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::isMainDisplay + */ + [[nodiscard]] bool isMainDisplay(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::isActive + */ + [[nodiscard]] bool isActive(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::isOnline + */ + [[nodiscard]] bool isOnline(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::getMirrorMaster + */ + [[nodiscard]] MacDisplayId getMirrorMaster(MacDisplayId display_id) const override; + + /** + * @copydoc MacApiLayerInterface::setDisplayMode + */ + [[nodiscard]] bool setDisplayMode(MacDisplayId display_id, const MacDisplayMode &mode) override; + + /** + * @copydoc MacApiLayerInterface::setOriginPoint + */ + [[nodiscard]] bool setOriginPoint(MacDisplayId display_id, const Point &origin) override; + + /** + * @copydoc MacApiLayerInterface::setMirror + */ + [[nodiscard]] bool setMirror(MacDisplayId display_id, MacDisplayId master_display_id) override; + }; +} // namespace display_device diff --git a/src/macos/include/display_device/macos/mac_api_layer_interface.h b/src/macos/include/display_device/macos/mac_api_layer_interface.h new file mode 100644 index 0000000..6d77e2c --- /dev/null +++ b/src/macos/include/display_device/macos/mac_api_layer_interface.h @@ -0,0 +1,170 @@ +/** + * @file src/macos/include/display_device/macos/mac_api_layer_interface.h + * @brief Declarations for the MacApiLayerInterface. + */ +#pragma once + +// local includes +#include "types.h" + +namespace display_device { + /** + * @brief Lowest level macOS API wrapper for easy mocking. + */ + class MacApiLayerInterface { + public: + /** + * @brief Default virtual destructor. + */ + virtual ~MacApiLayerInterface() = default; + + /** + * @brief Check if display configuration APIs are accessible. + * @returns True if macOS display APIs can be called, false otherwise. + */ + [[nodiscard]] virtual bool isApiAccessAvailable() const = 0; + + /** + * @brief Stringify a macOS display API error code. + * @param error_code Error code to stringify. + * @returns String containing a readable error description. + */ + [[nodiscard]] virtual std::string getErrorString(MacApiError error_code) const = 0; + + /** + * @brief Query macOS for display identifiers. + * @param type Display list type to query. + * @returns Display identifiers matching the query. + */ + [[nodiscard]] virtual MacDisplayIdList getDisplayIds(MacQueryType type) const = 0; + + /** + * @brief Tell macOS that the user is active and displays should wake. + * @param reason Short human-readable reason for the wake assertion. + * @returns Power assertion id to release later, or empty optional on failure. + */ + [[nodiscard]] virtual std::optional declareUserActivity(const std::string &reason) = 0; + + /** + * @brief Create a power assertion that prevents user-idle display sleep. + * @param reason Short human-readable reason for the assertion. + * @returns Power assertion id to release later, or empty optional on failure. + */ + [[nodiscard]] virtual std::optional createDisplaySleepAssertion(const std::string &reason) = 0; + + /** + * @brief Release a macOS power assertion. + * @param assertion_id Assertion id returned by declareUserActivity or createDisplaySleepAssertion. + * @returns True if the assertion was released, false otherwise. + */ + [[nodiscard]] virtual bool releasePowerAssertion(MacPowerAssertionId assertion_id) = 0; + + /** + * @brief Get the library device id for a display. + * @param display_id Display to query. + * @returns Stable best-effort device id, or empty string if unavailable. + */ + [[nodiscard]] virtual std::string getDeviceId(MacDisplayId display_id) const = 0; + + /** + * @brief Get the current display mode. + * @param display_id Display to query. + * @returns Current display mode, or empty optional if unavailable. + */ + [[nodiscard]] virtual std::optional getCurrentDisplayMode(MacDisplayId display_id) const = 0; + + /** + * @brief Get available display modes for a display. + * @param display_id Display to query. + * @returns Available display modes. + */ + [[nodiscard]] virtual MacDisplayModeList getDisplayModes(MacDisplayId display_id) const = 0; + + /** + * @brief Get the macOS capture selector for a display. + * @param display_id Display to query. + * @returns Decimal CoreGraphics display id string, or empty string if unavailable. + */ + [[nodiscard]] virtual std::string getDisplayName(MacDisplayId display_id) const = 0; + + /** + * @brief Get a human-readable display name. + * @param display_id Display to query. + * @returns Friendly display name or empty string if unavailable. + */ + [[nodiscard]] virtual std::string getFriendlyName(MacDisplayId display_id) const = 0; + + /** + * @brief Get EDID byte array for a display. + * @param display_id Display to query. + * @returns EDID byte array, or an empty array if unavailable. + */ + [[nodiscard]] virtual std::vector getEdid(MacDisplayId display_id) const = 0; + + /** + * @brief Get the display scale value. + * @param display_id Display to query. + * @returns Display scale, or empty optional if unavailable. + */ + [[nodiscard]] virtual std::optional getDisplayScale(MacDisplayId display_id) const = 0; + + /** + * @brief Get the display origin point. + * @param display_id Display to query. + * @returns Display origin, or empty optional if unavailable. + */ + [[nodiscard]] virtual std::optional getOriginPoint(MacDisplayId display_id) const = 0; + + /** + * @brief Check whether a display is the main display. + * @param display_id Display to check. + * @returns True if the display is main, false otherwise. + */ + [[nodiscard]] virtual bool isMainDisplay(MacDisplayId display_id) const = 0; + + /** + * @brief Check whether a display is active. + * @param display_id Display to check. + * @returns True if the display is active, false otherwise. + */ + [[nodiscard]] virtual bool isActive(MacDisplayId display_id) const = 0; + + /** + * @brief Check whether a display is online. + * @param display_id Display to check. + * @returns True if the display is online, false otherwise. + */ + [[nodiscard]] virtual bool isOnline(MacDisplayId display_id) const = 0; + + /** + * @brief Get the display mirrored by the specified display. + * @param display_id Display to query. + * @returns Master display id, or zero if the display is not a secondary mirror. + */ + [[nodiscard]] virtual MacDisplayId getMirrorMaster(MacDisplayId display_id) const = 0; + + /** + * @brief Set the display mode for a display. + * @param display_id Display to modify. + * @param mode Mode to apply. + * @returns True if the display mode was applied, false otherwise. + */ + [[nodiscard]] virtual bool setDisplayMode(MacDisplayId display_id, const MacDisplayMode &mode) = 0; + + /** + * @brief Set the origin point for a display. + * @param display_id Display to modify. + * @param origin Origin point to apply. + * @returns True if the origin was applied, false otherwise. + */ + [[nodiscard]] virtual bool setOriginPoint(MacDisplayId display_id, const Point &origin) = 0; + + /** + * @brief Set a display as a mirror of another display. + * @param display_id Display to modify. + * @param master_display_id Master display to mirror. + * @returns True if mirroring was applied, false otherwise. + */ + [[nodiscard]] virtual bool setMirror(MacDisplayId display_id, MacDisplayId master_display_id) = 0; + }; +} // namespace display_device diff --git a/src/macos/include/display_device/macos/mac_api_utils.h b/src/macos/include/display_device/macos/mac_api_utils.h new file mode 100644 index 0000000..de71b31 --- /dev/null +++ b/src/macos/include/display_device/macos/mac_api_utils.h @@ -0,0 +1,36 @@ +/** + * @file src/macos/include/display_device/macos/mac_api_utils.h + * @brief Declarations for lower level macOS API utility functions. + */ +#pragma once + +// local includes +#include "types.h" + +/** + * @brief Shared utility-level code for macOS API wrappers. + */ +namespace display_device::mac_utils { + /** + * @brief Check if a macOS API error represents success. + * @param error_code Error code to check. + * @returns True if the error code represents success, false otherwise. + */ + [[nodiscard]] bool isSuccess(MacApiError error_code); + + /** + * @brief Check if two refresh rates are close enough to be treated as equal. + * @param lhs First refresh rate to compare. + * @param rhs Second refresh rate to compare. + * @returns True if the refresh rates are close enough, false otherwise. + */ + [[nodiscard]] bool fuzzyCompareRefreshRates(const Rational &lhs, const Rational &rhs); + + /** + * @brief Check if two macOS display modes are close enough to be treated as equal. + * @param lhs First mode to compare. + * @param rhs Second mode to compare. + * @returns True if resolution matches exactly and refresh rate is close enough. + */ + [[nodiscard]] bool fuzzyCompareModes(const MacDisplayMode &lhs, const MacDisplayMode &rhs); +} // namespace display_device::mac_utils diff --git a/src/macos/include/display_device/macos/mac_display_device.h b/src/macos/include/display_device/macos/mac_display_device.h new file mode 100644 index 0000000..3089965 --- /dev/null +++ b/src/macos/include/display_device/macos/mac_display_device.h @@ -0,0 +1,103 @@ +/** + * @file src/macos/include/display_device/macos/mac_display_device.h + * @brief Declarations for the MacDisplayDevice. + */ +#pragma once + +// system includes +#include +#include + +// local includes +#include "mac_api_layer_interface.h" +#include "mac_display_device_interface.h" + +namespace display_device { + /** + * @brief Default implementation for the MacDisplayDeviceInterface. + */ + class MacDisplayDevice: public MacDisplayDeviceInterface { + public: + /** + * @brief Default constructor for the class. + * @param m_api A pointer to the macOS API layer. Will throw on nullptr. + */ + explicit MacDisplayDevice(std::shared_ptr m_api); + + /** + * @copydoc MacDisplayDeviceInterface::isApiAccessAvailable + */ + [[nodiscard]] bool isApiAccessAvailable() const override; + + /** + * @copydoc MacDisplayDeviceInterface::enumAvailableDevices + */ + [[nodiscard]] EnumeratedDeviceList enumAvailableDevices() const override; + + /** + * @copydoc MacDisplayDeviceInterface::getDisplayName + */ + [[nodiscard]] std::string getDisplayName(const std::string &device_id) const override; + + /** + * @copydoc MacDisplayDeviceInterface::getCurrentTopology + */ + [[nodiscard]] MacActiveTopology getCurrentTopology() const override; + + /** + * @copydoc MacDisplayDeviceInterface::isTopologyValid + */ + [[nodiscard]] bool isTopologyValid(const MacActiveTopology &topology) const override; + + /** + * @copydoc MacDisplayDeviceInterface::isTopologyTheSame + */ + [[nodiscard]] bool isTopologyTheSame(const MacActiveTopology &lhs, const MacActiveTopology &rhs) const override; + + /** + * @copydoc MacDisplayDeviceInterface::setTopology + */ + [[nodiscard]] bool setTopology(const MacActiveTopology &new_topology) override; + + /** + * @copydoc MacDisplayDeviceInterface::getCurrentDisplayModes + */ + [[nodiscard]] MacDeviceDisplayModeMap getCurrentDisplayModes(const StringSet &device_ids) const override; + + /** + * @copydoc MacDisplayDeviceInterface::setDisplayModes + */ + [[nodiscard]] bool setDisplayModes(const MacDeviceDisplayModeMap &modes) override; + + /** + * @copydoc MacDisplayDeviceInterface::isPrimary + */ + [[nodiscard]] bool isPrimary(const std::string &device_id) const override; + + /** + * @copydoc MacDisplayDeviceInterface::setAsPrimary + */ + [[nodiscard]] bool setAsPrimary(const std::string &device_id) override; + + /** + * @copydoc MacDisplayDeviceInterface::getCurrentHdrStates + */ + [[nodiscard]] MacHdrStateMap getCurrentHdrStates(const StringSet &device_ids) const override; + + /** + * @copydoc MacDisplayDeviceInterface::setHdrStates + */ + [[nodiscard]] bool setHdrStates(const MacHdrStateMap &states) override; + + private: + /** + * @brief Resolve a library device id to a CoreGraphics display id. + * @param device_id Device id to resolve. + * @param query_type Display list type to search. + * @return Display id, or empty optional if not found. + */ + [[nodiscard]] std::optional getDisplayId(std::string_view device_id, MacQueryType query_type) const; + + std::shared_ptr m_m_api; + }; +} // namespace display_device diff --git a/src/macos/include/display_device/macos/mac_display_device_interface.h b/src/macos/include/display_device/macos/mac_display_device_interface.h new file mode 100644 index 0000000..29f3704 --- /dev/null +++ b/src/macos/include/display_device/macos/mac_display_device_interface.h @@ -0,0 +1,110 @@ +/** + * @file src/macos/include/display_device/macos/mac_display_device_interface.h + * @brief Declarations for the MacDisplayDeviceInterface. + */ +#pragma once + +// local includes +#include "types.h" + +namespace display_device { + /** + * @brief Higher level abstracted API for interacting with macOS display devices. + */ + class MacDisplayDeviceInterface { + public: + /** + * @brief Default virtual destructor. + */ + virtual ~MacDisplayDeviceInterface() = default; + + /** + * @brief Check if the API for changing display settings is accessible. + * @returns True if display settings can be changed, false otherwise. + */ + [[nodiscard]] virtual bool isApiAccessAvailable() const = 0; + + /** + * @brief Enumerate the available display devices. + * @returns A list of available devices. Empty list can also indicate an error. + */ + [[nodiscard]] virtual EnumeratedDeviceList enumAvailableDevices() const = 0; + + /** + * @brief Get the macOS capture selector associated with the device. + * @param device_id A device to get display name for. + * @returns Decimal CoreGraphics display id string, or an empty string if not found. + */ + [[nodiscard]] virtual std::string getDisplayName(const std::string &device_id) const = 0; + + /** + * @brief Get the active topology. + * @returns Active topology, or an empty topology if unavailable. + */ + [[nodiscard]] virtual MacActiveTopology getCurrentTopology() const = 0; + + /** + * @brief Verify if the active topology is valid. + * @param topology Topology to validate. + * @returns True if valid, false otherwise. + */ + [[nodiscard]] virtual bool isTopologyValid(const MacActiveTopology &topology) const = 0; + + /** + * @brief Check if the topologies are close enough to be considered the same by macOS. + * @param lhs First topology to compare. + * @param rhs Second topology to compare. + * @returns True if topologies are the same, false otherwise. + */ + [[nodiscard]] virtual bool isTopologyTheSame(const MacActiveTopology &lhs, const MacActiveTopology &rhs) const = 0; + + /** + * @brief Set a new active topology. + * @param new_topology New topology to set. + * @returns True if the new topology has been set, false otherwise. + */ + [[nodiscard]] virtual bool setTopology(const MacActiveTopology &new_topology) = 0; + + /** + * @brief Get current display modes for the devices. + * @param device_ids Devices to get modes for. + * @returns Display mode map, or an empty map if unavailable. + */ + [[nodiscard]] virtual MacDeviceDisplayModeMap getCurrentDisplayModes(const StringSet &device_ids) const = 0; + + /** + * @brief Set new display modes for the devices. + * @param modes Modes to set. + * @returns True if modes were set, false otherwise. + */ + [[nodiscard]] virtual bool setDisplayModes(const MacDeviceDisplayModeMap &modes) = 0; + + /** + * @brief Check whether the specified device is primary. + * @param device_id Device to perform the check for. + * @returns True if the device is primary, false otherwise. + */ + [[nodiscard]] virtual bool isPrimary(const std::string &device_id) const = 0; + + /** + * @brief Set the device as a primary display. + * @param device_id Device to set as primary. + * @returns True if the device is or was set as primary, false otherwise. + */ + [[nodiscard]] virtual bool setAsPrimary(const std::string &device_id) = 0; + + /** + * @brief Get HDR state for the devices. + * @param device_ids Devices to get HDR states for. + * @returns HDR states per device, or an empty map if unavailable. + */ + [[nodiscard]] virtual MacHdrStateMap getCurrentHdrStates(const StringSet &device_ids) const = 0; + + /** + * @brief Set HDR states for the devices. + * @param states HDR states to set. + * @returns True if HDR states were set or no changes were needed, false otherwise. + */ + [[nodiscard]] virtual bool setHdrStates(const MacHdrStateMap &states) = 0; + }; +} // namespace display_device diff --git a/src/macos/include/display_device/macos/persistent_state.h b/src/macos/include/display_device/macos/persistent_state.h new file mode 100644 index 0000000..fe0ad4b --- /dev/null +++ b/src/macos/include/display_device/macos/persistent_state.h @@ -0,0 +1,50 @@ +/** + * @file src/macos/include/display_device/macos/persistent_state.h + * @brief Declarations for the MacPersistentState. + */ +#pragma once + +// system includes +#include + +// local includes +#include "display_device/settings_persistence_interface.h" +#include "types.h" + +namespace display_device { + /** + * @brief A wrapper around SettingsPersistenceInterface and cached macOS state. + */ + class MacPersistentState { + public: + /** + * @brief Default constructor for the class. + * @param settings_persistence_api Optional settings persistence interface. + * @param throw_on_load_error Specify whether to throw in constructor if settings fail to load. + */ + explicit MacPersistentState(std::shared_ptr settings_persistence_api, bool throw_on_load_error = false); + + /** + * @brief Store the new state via the interface and cache it. + * @param state New state to set. + * @return True if the state was successfully updated, false otherwise. + */ + [[nodiscard]] bool persistState(const std::optional &state); + + /** + * @brief Get cached state. + * @return Cached state. + */ + [[nodiscard]] const std::optional &getState() const; + + /** + * @brief Get the settings persistence API. + * @returns Settings persistence API. + */ + [[nodiscard]] const std::shared_ptr &getSettingsPersistenceApi() const; + + private: + std::shared_ptr m_settings_persistence_api; + std::optional m_cached_state; + }; +} // namespace display_device diff --git a/src/macos/include/display_device/macos/settings_manager.h b/src/macos/include/display_device/macos/settings_manager.h new file mode 100644 index 0000000..abdfcc8 --- /dev/null +++ b/src/macos/include/display_device/macos/settings_manager.h @@ -0,0 +1,78 @@ +/** + * @file src/macos/include/display_device/macos/settings_manager.h + * @brief Declarations for the MacSettingsManager. + */ +#pragma once + +// system includes +#include + +// local includes +#include "display_device/audio_context_interface.h" +#include "display_device/settings_manager_interface.h" +#include "mac_display_device_interface.h" +#include "persistent_state.h" + +namespace display_device { + /** + * @brief Default macOS implementation for the SettingsManagerInterface. + * + * macOS v1a supports `SingleDisplayConfiguration::DevicePreparation::VerifyOnly` + * with active-display resolution and refresh-rate changes. HDR writes and topology + * preparation modes such as `EnsureActive`, `EnsurePrimary`, and + * `EnsureOnlyDisplay` fail explicitly. + */ + class MacSettingsManager: public SettingsManagerInterface { + public: + /** + * @brief Default constructor for the class. + * @param dd_api A pointer to the macOS Display Device interface. Will throw on nullptr. + * @param audio_context_api Optional Audio Context interface. + * @param persistent_state A pointer to a class for managing persistence. + * @param workarounds Workaround settings for the APIs. + */ + explicit MacSettingsManager( + std::shared_ptr dd_api, + std::shared_ptr audio_context_api, + std::unique_ptr persistent_state, + MacWorkarounds workarounds + ); + + /** + * @copydoc SettingsManagerInterface::enumAvailableDevices + */ + [[nodiscard]] EnumeratedDeviceList enumAvailableDevices() const override; + + /** + * @copydoc SettingsManagerInterface::getDisplayName + */ + [[nodiscard]] std::string getDisplayName(const std::string &device_id) const override; + + /** + * @copydoc SettingsManagerInterface::applySettings + */ + [[nodiscard]] ApplyResult applySettings(const SingleDisplayConfiguration &config) override; + + /** + * @copydoc SettingsManagerInterface::revertSettings + */ + [[nodiscard]] RevertResult revertSettings() override; + + /** + * @copydoc SettingsManagerInterface::resetPersistence + */ + [[nodiscard]] bool resetPersistence() override; + + /** + * @brief Get the audio context API. + * @returns Audio context API. + */ + [[nodiscard]] const std::shared_ptr &getAudioContextApi() const; + + private: + std::shared_ptr m_dd_api; + std::shared_ptr m_audio_context_api; + std::unique_ptr m_persistence_state; + [[no_unique_address]] MacWorkarounds m_workarounds; + }; +} // namespace display_device diff --git a/src/macos/include/display_device/macos/settings_utils.h b/src/macos/include/display_device/macos/settings_utils.h new file mode 100644 index 0000000..6cdf4ed --- /dev/null +++ b/src/macos/include/display_device/macos/settings_utils.h @@ -0,0 +1,85 @@ +/** + * @file src/macos/include/display_device/macos/settings_utils.h + * @brief Declarations for macOS settings utility functions. + */ +#pragma once + +// local includes +#include "mac_display_device_interface.h" +#include "types.h" + +/** + * @brief Shared utility-level code for macOS settings. + */ +namespace display_device::mac_utils { + /** + * @brief Get all the device ids in the topology. + * @param topology Topology to flatten. + * @return Device ids found in the topology. + */ + [[nodiscard]] StringSet flattenTopology(const MacActiveTopology &topology); + + /** + * @brief Get one primary device from the provided topology. + * @param mac_dd Interface for interacting with the OS. + * @param topology Topology to search. + * @return Primary device id, or an empty string if none can be found. + */ + [[nodiscard]] std::string getPrimaryDevice(const MacDisplayDeviceInterface &mac_dd, const MacActiveTopology &topology); + + /** + * @brief Compute the initial state that should be used for future reverts. + * @param prev_state Previous initial state if one was persisted. + * @param topology_before_changes Current topology before applying a new configuration. + * @param devices Currently available devices. + * @return Initial state, or empty optional if the state cannot be computed. + */ + [[nodiscard]] std::optional computeInitialState( + const std::optional &prev_state, + const MacActiveTopology &topology_before_changes, + const EnumeratedDeviceList &devices + ); + + /** + * @brief Remove unavailable devices from a stored initial state. + * @param initial_state State to strip. + * @param devices Currently available devices. + * @return Stripped state, or empty optional if no usable state remains. + */ + [[nodiscard]] std::optional stripInitialState( + const MacSingleDisplayConfigState::Initial &initial_state, + const EnumeratedDeviceList &devices + ); + + /** + * @brief Compute display modes requested by a single-display configuration. + * @param resolution Optional resolution override. + * @param refresh_rate Optional refresh-rate override. + * @param configuring_primary_devices True when an empty device id selected primary devices. + * @param device_to_configure Main device being configured. + * @param additional_devices_to_configure Additional devices mirrored with the main device. + * @param original_modes Current or persisted display modes used as the base. + * @return New mode map with requested changes applied. + */ + [[nodiscard]] MacDeviceDisplayModeMap computeNewDisplayModes( + const std::optional &resolution, + const std::optional &refresh_rate, + bool configuring_primary_devices, + const std::string &device_to_configure, + const StringSet &additional_devices_to_configure, + const MacDeviceDisplayModeMap &original_modes + ); + + /** + * @brief Make a guard function for display modes. + * @param mac_dd Interface for interacting with the OS. + * @param modes Display modes to restore when the guard runs. + * @return Function that tries to restore the provided modes. + */ + [[nodiscard]] MacDdGuardFn modeGuardFn(MacDisplayDeviceInterface &mac_dd, const MacDeviceDisplayModeMap &modes); + + /** + * @brief Function that does nothing. + */ + void noopGuard(); +} // namespace display_device::mac_utils diff --git a/src/macos/include/display_device/macos/types.h b/src/macos/include/display_device/macos/types.h new file mode 100644 index 0000000..2117840 --- /dev/null +++ b/src/macos/include/display_device/macos/types.h @@ -0,0 +1,150 @@ +/** + * @file src/macos/include/display_device/macos/types.h + * @brief Declarations for macOS specific display device types. + */ +#pragma once + +// system includes +#include +#include +#include +#include + +// local includes +#include "display_device/types.h" + +namespace display_device { + /** + * @brief Error code returned by macOS display APIs. + */ + using MacApiError = int; + + /** + * @brief CoreGraphics display identifier. + * + * CoreGraphics exposes display identifiers as 32-bit values. The platform + * layer keeps this type independent from CoreGraphics headers so public + * headers remain easy to parse on non-Apple hosts. + */ + using MacDisplayId = std::uint32_t; + + /** + * @brief macOS power assertion identifier. + * + * IOKit exposes power assertion identifiers as 32-bit values. The platform + * layer keeps this type independent from IOKit headers so public headers + * remain easy to parse on non-Apple hosts. + */ + using MacPowerAssertionId = std::uint32_t; + + /** + * @brief A list of CoreGraphics display identifiers. + */ + using MacDisplayIdList = std::vector; + + /** + * @brief Type of display list to query from macOS. + */ + enum class MacQueryType { + Active, ///< Displays that are drawable. + Online ///< Displays that are connected to the system. + }; + + /** + * @brief A LIST[LIST[DEVICE_ID]] structure which represents active macOS display topology. + * + * Each inner list is a mirrored display group. Each top-level entry is an + * extended display region. + */ + using MacActiveTopology = std::vector>; + + /** + * @brief Display mode data used by the macOS backend. + */ + struct MacDisplayMode { + Resolution m_resolution {}; ///< Display resolution in pixels. + Rational m_refresh_rate {}; ///< Display refresh rate. + + /** + * @brief Comparator for strict equality. + */ + friend bool operator==(const MacDisplayMode &lhs, const MacDisplayMode &rhs) = default; + }; + + /** + * @brief A list of macOS display modes. + */ + using MacDisplayModeList = std::vector; + + /** + * @brief Ordered map of [DEVICE_ID -> MacDisplayMode]. + */ + using MacDeviceDisplayModeMap = StringMap; + + /** + * @brief Ordered map of [DEVICE_ID -> std::optional]. + */ + using MacHdrStateMap = StringMap>; + + /** + * @brief Arbitrary macOS data for making and undoing settings changes. + */ + struct MacSingleDisplayConfigState { + /** + * @brief Original system state used as a base for revert operations. + */ + struct Initial { + MacActiveTopology m_topology {}; ///< Original active topology. + StringSet m_primary_devices {}; ///< Original primary device IDs. + + /** + * @brief Comparator for strict equality. + */ + friend bool operator==(const Initial &lhs, const Initial &rhs) = default; + }; + + /** + * @brief System state modified by this library. + */ + struct Modified { + MacActiveTopology m_topology {}; ///< Modified active topology. + MacDeviceDisplayModeMap m_original_modes {}; ///< Original display modes before modification. + MacHdrStateMap m_original_hdr_states {}; ///< Original HDR states before modification. + std::string m_original_primary_device {}; ///< Original primary device before modification. + + /** + * @brief Check if the changed topology has any other modifications. + * @return True if DisplayMode, HDR or primary device has been changed, false otherwise. + */ + [[nodiscard]] bool hasModifications() const; + + /** + * @brief Comparator for strict equality. + */ + friend bool operator==(const Modified &lhs, const Modified &rhs) = default; + }; + + Initial m_initial; ///< Initial system state. + Modified m_modified; ///< Modified system state. + + /** + * @brief Comparator for strict equality. + */ + friend bool operator==(const MacSingleDisplayConfigState &lhs, const MacSingleDisplayConfigState &rhs) = default; + }; + + /** + * @brief Default function type used for cleanup/guard functions. + */ + using MacDdGuardFn = std::function; + + /** + * @brief Settings for macOS-specific workarounds. + */ + struct MacWorkarounds { + /** + * @brief Comparator for strict equality. + */ + friend bool operator==(const MacWorkarounds &lhs, const MacWorkarounds &rhs) = default; + }; +} // namespace display_device diff --git a/src/macos/json.cpp b/src/macos/json.cpp new file mode 100644 index 0000000..d804954 --- /dev/null +++ b/src/macos/json.cpp @@ -0,0 +1,20 @@ +/** + * @file src/macos/json.cpp + * @brief Definitions for JSON conversion functions (macOS-only). + */ +// header include +#include "display_device/macos/json.h" + +// special ordered include of details +#define DD_JSON_DETAIL +// clang-format off +#include "display_device/macos/detail/json_serializer.h" +#include "display_device/detail/json_converter.h" +// clang-format on + +namespace display_device { + DD_JSON_DEFINE_CONVERTER(MacActiveTopology) + DD_JSON_DEFINE_CONVERTER(MacDeviceDisplayModeMap) + DD_JSON_DEFINE_CONVERTER(MacHdrStateMap) + DD_JSON_DEFINE_CONVERTER(MacSingleDisplayConfigState) +} // namespace display_device diff --git a/src/macos/json_serializer.cpp b/src/macos/json_serializer.cpp new file mode 100644 index 0000000..1692375 --- /dev/null +++ b/src/macos/json_serializer.cpp @@ -0,0 +1,18 @@ +/** + * @file src/macos/json_serializer.cpp + * @brief Definitions for private JSON serialization helpers (macOS-only). + */ +// special ordered include of details +#define DD_JSON_DETAIL +// clang-format off +#include "display_device/macos/types.h" +#include "display_device/macos/detail/json_serializer.h" +// clang-format on + +namespace display_device { + // Structs + DD_JSON_DEFINE_SERIALIZE_STRUCT(MacDisplayMode, resolution, refresh_rate) + DD_JSON_DEFINE_SERIALIZE_STRUCT(MacSingleDisplayConfigState::Initial, topology, primary_devices) + DD_JSON_DEFINE_SERIALIZE_STRUCT(MacSingleDisplayConfigState::Modified, topology, original_modes, original_hdr_states, original_primary_device) + DD_JSON_DEFINE_SERIALIZE_STRUCT(MacSingleDisplayConfigState, initial, modified) +} // namespace display_device diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp new file mode 100644 index 0000000..925b826 --- /dev/null +++ b/src/macos/mac_api_layer.cpp @@ -0,0 +1,755 @@ +/** + * @file src/macos/mac_api_layer.cpp + * @brief Definitions for the MacApiLayer. + */ +// class header include +#include "display_device/macos/mac_api_layer.h" + +// system includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "display_device/logging.h" +#include "display_device/macos/mac_api_utils.h" + +namespace display_device { + namespace { + /** + * @brief Small RAII wrapper for CoreFoundation objects. + */ + template + class CfPtr { + public: + /** + * @brief Default constructor. + */ + CfPtr() = default; + + /** + * @brief Construct from a CoreFoundation object. + * @param ref Reference to own. + */ + explicit CfPtr(T ref): + m_ref {ref} {} + + CfPtr(const CfPtr &) = delete; + CfPtr &operator=(const CfPtr &) = delete; + + /** + * @brief Move constructor. + * @param other Object to move from. + */ + CfPtr(CfPtr &&other) noexcept: + m_ref {other.m_ref} { + other.m_ref = nullptr; + } + + /** + * @brief Move assignment. + * @param other Object to move from. + * @return Reference to this object. + */ + CfPtr &operator=(CfPtr &&other) noexcept { + if (this != &other) { + reset(other.m_ref); + other.m_ref = nullptr; + } + + return *this; + } + + /** + * @brief Destructor. + */ + ~CfPtr() { + reset(nullptr); + } + + /** + * @brief Get the wrapped reference. + * @return Wrapped reference. + */ + [[nodiscard]] T get() const { + return m_ref; + } + + /** + * @brief Reset the wrapped reference. + * @param ref New reference to own. + */ + void reset(T ref) { + if (m_ref) { + CFRelease(m_ref); + } + + m_ref = ref; + } + + /** + * @brief Check if a reference is wrapped. + */ + explicit operator bool() const { + return m_ref != nullptr; + } + + private: + T m_ref {nullptr}; ///< Wrapped reference. + }; + + /** + * @brief Small RAII wrapper for IOKit objects. + */ + class IoObject { + public: + /** + * @brief Construct from an IOKit object. + * @param object Object to own. + */ + explicit IoObject(const io_object_t object): + m_object {object} {} + + IoObject(const IoObject &) = delete; + IoObject &operator=(const IoObject &) = delete; + + /** + * @brief Destructor. + */ + ~IoObject() { + if (m_object != IO_OBJECT_NULL) { + IOObjectRelease(m_object); + } + } + + private: + io_object_t m_object {IO_OBJECT_NULL}; ///< Wrapped object. + }; + + /** + * @brief Convert a size to unsigned int without overflowing. + * @param value Value to convert. + * @return Converted value. + */ + [[nodiscard]] unsigned int toUnsignedInt(const std::size_t value) { + return static_cast(std::min(value, std::numeric_limits::max())); + } + + /** + * @brief Convert a floating point refresh rate to a rational value. + * @param value Floating point refresh rate. + * @return Rational refresh rate. + */ + [[nodiscard]] Rational toRationalRefreshRate(const double value) { + if (!std::isfinite(value) || value <= 0.) { + return {0, 1}; + } + + constexpr std::uint64_t denominator {1000}; + const auto rounded_numerator {static_cast(std::llround(value * static_cast(denominator)))}; + if (rounded_numerator == 0) { + return {0, 1}; + } + + const auto divisor {std::gcd(rounded_numerator, denominator)}; + return { + static_cast(std::min(rounded_numerator / divisor, std::numeric_limits::max())), + static_cast(denominator / divisor) + }; + } + + /** + * @brief Convert a CoreGraphics mode to a library mode. + * @param mode Mode to convert. + * @return Converted mode, or empty optional if mode data is unusable. + */ + [[nodiscard]] std::optional toDisplayMode(CGDisplayModeRef mode) { + if (!mode) { + return std::nullopt; + } + + auto width {CGDisplayModeGetPixelWidth(mode)}; + auto height {CGDisplayModeGetPixelHeight(mode)}; + if (width == 0 || height == 0) { + width = CGDisplayModeGetWidth(mode); + height = CGDisplayModeGetHeight(mode); + } + + if (width == 0 || height == 0) { + return std::nullopt; + } + + return MacDisplayMode { + {toUnsignedInt(width), toUnsignedInt(height)}, + toRationalRefreshRate(CGDisplayModeGetRefreshRate(mode)) + }; + } + + /** + * @brief Convert a CoreFoundation string to a standard string. + * @param value String to convert. + * @return Converted string, or an empty string on failure. + */ + [[nodiscard]] std::string toString(CFStringRef value) { + if (!value) { + return {}; + } + + if (const auto *direct_c_string {CFStringGetCStringPtr(value, kCFStringEncodingUTF8)}) { + return direct_c_string; + } + + const auto length {CFStringGetLength(value)}; + CFIndex byte_count {0}; + const auto converted_length {CFStringGetBytes(value, CFRangeMake(0, length), kCFStringEncodingUTF8, 0, false, nullptr, 0, &byte_count)}; + if (converted_length != length || byte_count < 0) { + return {}; + } + + std::string result(static_cast(byte_count), '\0'); + if (byte_count == 0) { + return result; + } + + CFIndex written_byte_count {0}; + const auto written_length { + CFStringGetBytes(value, CFRangeMake(0, length), kCFStringEncodingUTF8, 0, false, reinterpret_cast(result.data()), byte_count, &written_byte_count) + }; + if (written_length != length || written_byte_count != byte_count) { + return {}; + } + + return result; + } + + /** + * @brief Convert a standard string to a CoreFoundation string. + * @param value String to convert. + * @return Converted string, or null on failure. + */ + [[nodiscard]] CfPtr toCfString(const std::string &value) { + return CfPtr {CFStringCreateWithCString(kCFAllocatorDefault, value.c_str(), kCFStringEncodingUTF8)}; + } + + /** + * @brief Get a CoreFoundation dictionary value with type validation. + * @param dictionary Dictionary to query. + * @param key Key to query. + * @param expected_type Expected CoreFoundation type id. + * @return Value when present and type matches. + */ + [[nodiscard]] CFTypeRef getTypedValue(CFDictionaryRef dictionary, CFStringRef key, const CFTypeID expected_type) { + if (!dictionary || !key) { + return nullptr; + } + + const auto value {CFDictionaryGetValue(dictionary, key)}; + if (!value || CFGetTypeID(value) != expected_type) { + return nullptr; + } + + return value; + } + + /** + * @brief Get an unsigned integer from a CoreFoundation dictionary. + * @param dictionary Dictionary to query. + * @param key Key to query. + * @return Converted value, or empty optional on failure. + */ + [[nodiscard]] std::optional getUInt32(CFDictionaryRef dictionary, CFStringRef key) { + const auto number {static_cast(getTypedValue(dictionary, key, CFNumberGetTypeID()))}; + if (!number) { + return std::nullopt; + } + + std::uint32_t result {}; + if (!CFNumberGetValue(number, kCFNumberSInt32Type, &result)) { + return std::nullopt; + } + + return result; + } + + /** + * @brief Get a string from a CoreFoundation dictionary. + * @param dictionary Dictionary to query. + * @param key Key to query. + * @return Converted value. + */ + [[nodiscard]] std::string getString(CFDictionaryRef dictionary, CFStringRef key) { + return toString(static_cast(getTypedValue(dictionary, key, CFStringGetTypeID()))); + } + + /** + * @brief Get the preferred display product name from an IOKit dictionary. + * @param dictionary Dictionary to query. + * @return Product name, or an empty string if unavailable. + */ + [[nodiscard]] std::string getProductName(CFDictionaryRef dictionary) { + const auto value {CFDictionaryGetValue(dictionary, CFSTR(kDisplayProductName))}; + if (!value) { + return {}; + } + + if (CFGetTypeID(value) == CFStringGetTypeID()) { + return toString(static_cast(value)); + } + + if (CFGetTypeID(value) != CFDictionaryGetTypeID()) { + return {}; + } + + const auto names {static_cast(value)}; + const auto count {CFDictionaryGetCount(names)}; + std::vector values(static_cast(count), nullptr); + CFDictionaryGetKeysAndValues(names, nullptr, values.data()); + + for (const auto *name : values) { + if (name && CFGetTypeID(name) == CFStringGetTypeID()) { + if (auto converted {toString(static_cast(name))}; !converted.empty()) { + return converted; + } + } + } + + return {}; + } + + /** + * @brief Score how well an IOKit dictionary matches a CoreGraphics display. + * @param dictionary Dictionary to score. + * @param display_id Display to match. + * @return Negative value when incompatible, otherwise match score. + */ + [[nodiscard]] int getMatchScore(CFDictionaryRef dictionary, const MacDisplayId display_id) { + int score {0}; + + const auto check_number = [&score](const std::optional &value, const std::uint32_t expected, const int weight) { + if (!value.has_value()) { + return true; + } + + if (expected != 0 && *value != expected) { + return false; + } + + score += weight; + return true; + }; + + if (!check_number(getUInt32(dictionary, CFSTR(kDisplayVendorID)), CGDisplayVendorNumber(display_id), 4)) { + return -1; + } + + if (!check_number(getUInt32(dictionary, CFSTR(kDisplayProductID)), CGDisplayModelNumber(display_id), 4)) { + return -1; + } + + if (!check_number(getUInt32(dictionary, CFSTR(kDisplaySerialNumber)), CGDisplaySerialNumber(display_id), 8)) { + return -1; + } + + return score > 0 ? score : -1; + } + + /** + * @brief Copy the best matching IOKit dictionary for a display. + * @param display_id Display to query. + * @return IOKit display dictionary, or null if unavailable. + */ + [[nodiscard]] CfPtr copyDisplayInfo(const MacDisplayId display_id) { + auto matching {IOServiceMatching("IODisplayConnect")}; + if (!matching) { + return {}; + } + + io_iterator_t iterator {IO_OBJECT_NULL}; + if (IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) != KERN_SUCCESS || iterator == IO_OBJECT_NULL) { + return {}; + } + + IoObject iterator_guard {iterator}; + CfPtr best_dictionary; + int best_score {-1}; + + while (const auto service = IOIteratorNext(iterator)) { + IoObject service_guard {service}; + CfPtr dictionary {IODisplayCreateInfoDictionary(service, kIODisplayOnlyPreferredName)}; + if (!dictionary) { + continue; + } + + const auto score {getMatchScore(dictionary.get(), display_id)}; + if (score > best_score) { + best_score = score; + best_dictionary.reset(static_cast(CFRetain(dictionary.get()))); + } + } + + return best_dictionary; + } + + /** + * @brief Build metadata text from an IOKit dictionary. + * @param dictionary Dictionary to query. + * @return Metadata string. + */ + [[nodiscard]] std::string makeIokitMetadata(CFDictionaryRef dictionary) { + if (!dictionary) { + return {}; + } + + std::ostringstream metadata; + metadata << "vendor=" << getUInt32(dictionary, CFSTR(kDisplayVendorID)).value_or(0) << ';' + << "product=" << getUInt32(dictionary, CFSTR(kDisplayProductID)).value_or(0) << ';' + << "serial=" << getUInt32(dictionary, CFSTR(kDisplaySerialNumber)).value_or(0) << ';' + << "serial_string=" << getString(dictionary, CFSTR(kDisplaySerialString)) << ';' + << "location=" << getString(dictionary, CFSTR(kIODisplayLocationKey)) << ';' + << "name=" << getProductName(dictionary); + return metadata.str(); + } + + /** + * @brief Calculate FNV-1a hash for a byte range. + * @param bytes Bytes to hash. + * @return Hash value. + */ + [[nodiscard]] std::uint64_t hashBytes(const std::vector &bytes) { + std::uint64_t hash {14695981039346656037ULL}; + for (const auto byte : bytes) { + hash ^= static_cast(std::to_integer(byte)); + hash *= 1099511628211ULL; + } + + return hash; + } + + /** + * @brief Calculate FNV-1a hash for text. + * @param text Text to hash. + * @return Hash value. + */ + [[nodiscard]] std::uint64_t hashText(const std::string &text) { + std::vector bytes; + bytes.reserve(text.size()); + for (const auto character : text) { + bytes.push_back(static_cast(static_cast(character))); + } + + return hashBytes(bytes); + } + + /** + * @brief Format a device id. + * @param prefix Prefix describing the id source. + * @param hash Hash value. + * @return Formatted device id. + */ + [[nodiscard]] std::string makeDeviceId(const std::string_view prefix, const std::uint64_t hash) { + return std::format("macos-{}-{:016x}", prefix, hash); + } + } // namespace + + bool MacApiLayer::isApiAccessAvailable() const { + std::uint32_t display_count {0}; + return CGGetActiveDisplayList(0, nullptr, &display_count) == kCGErrorSuccess; + } + + std::string MacApiLayer::getErrorString(const MacApiError error_code) const { + std::ostringstream error; + error << "[code: " << error_code << "] "; + + switch (error_code) { + case kCGErrorSuccess: + error << "Success"; + break; + case kCGErrorFailure: + error << "Failure"; + break; + case kCGErrorIllegalArgument: + error << "Illegal argument"; + break; + case kCGErrorInvalidConnection: + error << "Invalid connection"; + break; + case kCGErrorInvalidContext: + error << "Invalid context"; + break; + case kCGErrorCannotComplete: + error << "Cannot complete"; + break; + case kCGErrorNotImplemented: + error << "Not implemented"; + break; + case kCGErrorRangeCheck: + error << "Range check failed"; + break; + case kCGErrorTypeCheck: + error << "Type check failed"; + break; + case kCGErrorInvalidOperation: + error << "Invalid operation"; + break; + case kCGErrorNoneAvailable: + error << "None available"; + break; + default: + error << "Unknown CoreGraphics error"; + break; + } + + return error.str(); + } + + MacDisplayIdList MacApiLayer::getDisplayIds(const MacQueryType type) const { + using GetDisplayListFn = CGError (*)(std::uint32_t, CGDirectDisplayID *, std::uint32_t *); + + const GetDisplayListFn get_display_list {type == MacQueryType::Active ? CGGetActiveDisplayList : CGGetOnlineDisplayList}; + + std::uint32_t display_count {0}; + if (get_display_list(0, nullptr, &display_count) != kCGErrorSuccess || display_count == 0) { + return {}; + } + + MacDisplayIdList displays(display_count); + if (get_display_list(display_count, displays.data(), &display_count) != kCGErrorSuccess) { + return {}; + } + + displays.resize(display_count); + return displays; + } + + std::optional MacApiLayer::declareUserActivity(const std::string &reason) { + const auto assertion_reason {toCfString(reason.empty() ? "libdisplaydevice display detection" : reason)}; + if (!assertion_reason) { + DD_LOG(error) << "Failed to create macOS display wake assertion reason."; + return std::nullopt; + } + + IOPMAssertionID assertion_id {}; + const auto result {IOPMAssertionDeclareUserActivity(assertion_reason.get(), kIOPMUserActiveRemote, &assertion_id)}; + if (result != kIOReturnSuccess) { + DD_LOG(error) << "Failed to declare macOS user activity for display wake: " << result; + return std::nullopt; + } + + return assertion_id; + } + + std::optional MacApiLayer::createDisplaySleepAssertion(const std::string &reason) { + const auto assertion_reason {toCfString(reason.empty() ? "libdisplaydevice display capture" : reason)}; + if (!assertion_reason) { + DD_LOG(error) << "Failed to create macOS display sleep assertion reason."; + return std::nullopt; + } + + IOPMAssertionID assertion_id {}; + const auto result {IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleDisplaySleep, kIOPMAssertionLevelOn, assertion_reason.get(), &assertion_id)}; + if (result != kIOReturnSuccess) { + DD_LOG(error) << "Failed to create macOS display sleep assertion: " << result; + return std::nullopt; + } + + return assertion_id; + } + + bool MacApiLayer::releasePowerAssertion(const MacPowerAssertionId assertion_id) { + const auto result {IOPMAssertionRelease(assertion_id)}; + if (result != kIOReturnSuccess) { + DD_LOG(error) << "Failed to release macOS power assertion " << assertion_id << ": " << result; + return false; + } + + return true; + } + + std::string MacApiLayer::getDeviceId(const MacDisplayId display_id) const { + const auto edid {getEdid(display_id)}; + if (!edid.empty()) { + return makeDeviceId("edid", hashBytes(edid)); + } + + if (const auto dictionary {copyDisplayInfo(display_id)}) { + if (const auto metadata {makeIokitMetadata(dictionary.get())}; !metadata.empty()) { + return makeDeviceId("iokit", hashText(metadata)); + } + } + + std::ostringstream fallback; + fallback << "vendor=" << CGDisplayVendorNumber(display_id) << ';' + << "model=" << CGDisplayModelNumber(display_id) << ';' + << "serial=" << CGDisplaySerialNumber(display_id) << ';' + << "unit=" << CGDisplayUnitNumber(display_id) << ';' + << "display=" << display_id; + return makeDeviceId("cg", hashText(fallback.str())); + } + + std::optional MacApiLayer::getCurrentDisplayMode(const MacDisplayId display_id) const { + const auto mode {CGDisplayCopyDisplayMode(display_id)}; + if (!mode) { + return std::nullopt; + } + + const auto result {toDisplayMode(mode)}; + CGDisplayModeRelease(mode); + return result; + } + + MacDisplayModeList MacApiLayer::getDisplayModes(const MacDisplayId display_id) const { + CfPtr modes_ref {CGDisplayCopyAllDisplayModes(display_id, nullptr)}; + if (!modes_ref) { + return {}; + } + + MacDisplayModeList modes; + const auto mode_count {CFArrayGetCount(modes_ref.get())}; + for (CFIndex index = 0; index < mode_count; ++index) { + const auto mode {static_cast(const_cast(CFArrayGetValueAtIndex(modes_ref.get(), index)))}; + if (!mode || !CGDisplayModeIsUsableForDesktopGUI(mode)) { + continue; + } + + if (const auto converted_mode {toDisplayMode(mode)}; converted_mode && std::ranges::find(modes, *converted_mode) == std::end(modes)) { + modes.push_back(*converted_mode); + } + } + + return modes; + } + + std::string MacApiLayer::getDisplayName(const MacDisplayId display_id) const { + return std::to_string(display_id); + } + + std::string MacApiLayer::getFriendlyName(const MacDisplayId display_id) const { + if (const auto dictionary {copyDisplayInfo(display_id)}) { + return getProductName(dictionary.get()); + } + + return {}; + } + + std::vector MacApiLayer::getEdid(const MacDisplayId display_id) const { + const auto dictionary {copyDisplayInfo(display_id)}; + if (!dictionary) { + return {}; + } + + const auto edid {static_cast(getTypedValue(dictionary.get(), CFSTR(kIODisplayEDIDKey), CFDataGetTypeID()))}; + if (!edid) { + return {}; + } + + const auto length {CFDataGetLength(edid)}; + const auto *bytes {CFDataGetBytePtr(edid)}; + if (!bytes || length <= 0) { + return {}; + } + + std::vector result(static_cast(length)); + std::ranges::transform(bytes, bytes + length, std::begin(result), [](const auto byte) { + return static_cast(byte); + }); + return result; + } + + std::optional MacApiLayer::getDisplayScale(const MacDisplayId display_id) const { + const auto bounds {CGDisplayBounds(display_id)}; + if (bounds.size.width <= 0.) { + return std::nullopt; + } + + const auto pixel_width {CGDisplayPixelsWide(display_id)}; + if (pixel_width == 0) { + return std::nullopt; + } + + const auto scale {static_cast(pixel_width) / bounds.size.width}; + return Rational {static_cast(std::llround(scale * 100.)), 100}; + } + + std::optional MacApiLayer::getOriginPoint(const MacDisplayId display_id) const { + const auto bounds {CGDisplayBounds(display_id)}; + return Point { + static_cast(std::llround(bounds.origin.x)), + static_cast(std::llround(bounds.origin.y)) + }; + } + + bool MacApiLayer::isMainDisplay(const MacDisplayId display_id) const { + return CGDisplayIsMain(display_id) != 0; + } + + bool MacApiLayer::isActive(const MacDisplayId display_id) const { + return CGDisplayIsActive(display_id) != 0; + } + + bool MacApiLayer::isOnline(const MacDisplayId display_id) const { + return CGDisplayIsOnline(display_id) != 0; + } + + MacDisplayId MacApiLayer::getMirrorMaster(const MacDisplayId display_id) const { + return CGDisplayMirrorsDisplay(display_id); + } + + bool MacApiLayer::setDisplayMode(const MacDisplayId display_id, const MacDisplayMode &mode) { + CfPtr modes_ref {CGDisplayCopyAllDisplayModes(display_id, nullptr)}; + if (!modes_ref) { + DD_LOG(error) << "Failed to get available macOS display modes for " << display_id << "!"; + return false; + } + + CGDisplayModeRef matching_mode {nullptr}; + const auto mode_count {CFArrayGetCount(modes_ref.get())}; + for (CFIndex index = 0; index < mode_count; ++index) { + const auto candidate {static_cast(const_cast(CFArrayGetValueAtIndex(modes_ref.get(), index)))}; + if (!candidate || !CGDisplayModeIsUsableForDesktopGUI(candidate)) { + continue; + } + + const auto converted_mode {toDisplayMode(candidate)}; + if (converted_mode && mac_utils::fuzzyCompareModes(*converted_mode, mode)) { + matching_mode = candidate; + break; + } + } + + if (!matching_mode) { + DD_LOG(error) << "Failed to find a matching macOS display mode for " << display_id << "!"; + return false; + } + + const auto result {CGDisplaySetDisplayMode(display_id, matching_mode, nullptr)}; + if (result != kCGErrorSuccess) { + DD_LOG(error) << getErrorString(result) << " failed to set macOS display mode for " << display_id << "!"; + return false; + } + + return true; + } + + bool MacApiLayer::setOriginPoint(const MacDisplayId display_id, const Point &origin) { + static_cast(display_id); + static_cast(origin); + return false; + } + + bool MacApiLayer::setMirror(const MacDisplayId display_id, const MacDisplayId master_display_id) { + static_cast(display_id); + static_cast(master_display_id); + return false; + } +} // namespace display_device diff --git a/src/macos/mac_api_utils.cpp b/src/macos/mac_api_utils.cpp new file mode 100644 index 0000000..eec9b97 --- /dev/null +++ b/src/macos/mac_api_utils.cpp @@ -0,0 +1,30 @@ +/** + * @file src/macos/mac_api_utils.cpp + * @brief Definitions for lower level macOS API utility functions. + */ +// header include +#include "display_device/macos/mac_api_utils.h" + +// system includes +#include + +namespace display_device::mac_utils { + bool isSuccess(const MacApiError error_code) { + return error_code == 0; + } + + bool fuzzyCompareRefreshRates(const Rational &lhs, const Rational &rhs) { + if (lhs.m_denominator > 0 && rhs.m_denominator > 0) { + const double lhs_value {static_cast(lhs.m_numerator) / static_cast(lhs.m_denominator)}; + const double rhs_value {static_cast(rhs.m_numerator) / static_cast(rhs.m_denominator)}; + return std::abs(lhs_value - rhs_value) <= 0.9; + } + + return false; + } + + bool fuzzyCompareModes(const MacDisplayMode &lhs, const MacDisplayMode &rhs) { + return lhs.m_resolution == rhs.m_resolution && + fuzzyCompareRefreshRates(lhs.m_refresh_rate, rhs.m_refresh_rate); + } +} // namespace display_device::mac_utils diff --git a/src/macos/mac_display_device_general.cpp b/src/macos/mac_display_device_general.cpp new file mode 100644 index 0000000..03d27dc --- /dev/null +++ b/src/macos/mac_display_device_general.cpp @@ -0,0 +1,88 @@ +/** + * @file src/macos/mac_display_device_general.cpp + * @brief Definitions for the leftover general methods in MacDisplayDevice. + */ +// class header include +#include "display_device/macos/mac_display_device.h" + +// system includes +#include + +// local includes +#include "display_device/logging.h" + +namespace display_device { + MacDisplayDevice::MacDisplayDevice(std::shared_ptr m_api): + m_m_api {std::move(m_api)} { + if (!m_m_api) { + throw std::invalid_argument {"Nullptr provided for MacApiLayerInterface in MacDisplayDevice!"}; + } + } + + bool MacDisplayDevice::isApiAccessAvailable() const { + return m_m_api->isApiAccessAvailable(); + } + + EnumeratedDeviceList MacDisplayDevice::enumAvailableDevices() const { + EnumeratedDeviceList devices; + StringSet seen_device_ids; + + for (const auto display_id : m_m_api->getDisplayIds(MacQueryType::Online)) { + const auto device_id {m_m_api->getDeviceId(display_id)}; + if (device_id.empty() || !seen_device_ids.insert(device_id).second) { + continue; + } + + auto display_name {m_m_api->getDisplayName(display_id)}; + auto friendly_name {m_m_api->getFriendlyName(display_id)}; + if (friendly_name.empty()) { + friendly_name = display_name; + } + + const auto edid {EdidData::parse(m_m_api->getEdid(display_id))}; + + std::optional info; + if (m_m_api->isActive(display_id)) { + if (const auto current_mode {m_m_api->getCurrentDisplayMode(display_id)}) { + info = EnumeratedDevice::Info { + current_mode->m_resolution, + m_m_api->getDisplayScale(display_id).value_or(Rational {0, 1}), + current_mode->m_refresh_rate, + m_m_api->isMainDisplay(display_id), + m_m_api->getOriginPoint(display_id).value_or(Point {}), + std::nullopt + }; + } else { + DD_LOG(warning) << "Active macOS display is missing current mode: " << display_id; + } + } + + devices.emplace_back(device_id, display_name, friendly_name, edid, info); + } + + return devices; + } + + std::string MacDisplayDevice::getDisplayName(const std::string &device_id) const { + const auto display_id {getDisplayId(device_id, MacQueryType::Online)}; + if (!display_id.has_value()) { + return {}; + } + + return m_m_api->getDisplayName(*display_id); + } + + std::optional MacDisplayDevice::getDisplayId(const std::string_view device_id, const MacQueryType query_type) const { + if (device_id.empty()) { + return std::nullopt; + } + + for (const auto display_id : m_m_api->getDisplayIds(query_type)) { + if (m_m_api->getDeviceId(display_id) == device_id) { + return display_id; + } + } + + return std::nullopt; + } +} // namespace display_device diff --git a/src/macos/mac_display_device_hdr.cpp b/src/macos/mac_display_device_hdr.cpp new file mode 100644 index 0000000..e64cf10 --- /dev/null +++ b/src/macos/mac_display_device_hdr.cpp @@ -0,0 +1,26 @@ +/** + * @file src/macos/mac_display_device_hdr.cpp + * @brief Definitions for HDR related methods in MacDisplayDevice. + */ +// class header include +#include "display_device/macos/mac_display_device.h" + +// system includes +#include + +namespace display_device { + MacHdrStateMap MacDisplayDevice::getCurrentHdrStates(const StringSet &device_ids) const { + MacHdrStateMap states; + for (const auto &device_id : device_ids) { + states[device_id] = std::nullopt; + } + + return states; + } + + bool MacDisplayDevice::setHdrStates(const MacHdrStateMap &states) { + return std::ranges::all_of(states, [](const auto &entry) { + return !entry.second; + }); + } +} // namespace display_device diff --git a/src/macos/mac_display_device_modes.cpp b/src/macos/mac_display_device_modes.cpp new file mode 100644 index 0000000..f324c82 --- /dev/null +++ b/src/macos/mac_display_device_modes.cpp @@ -0,0 +1,148 @@ +/** + * @file src/macos/mac_display_device_modes.cpp + * @brief Definitions for display mode related methods in MacDisplayDevice. + */ +// class header include +#include "display_device/macos/mac_display_device.h" + +// system includes +#include + +// local includes +#include "display_device/logging.h" +#include "display_device/macos/mac_api_utils.h" + +namespace display_device { + namespace { + /** + * @brief Check if a mode list contains a matching mode. + * @param modes Mode list to search. + * @param mode Mode to search for. + * @return True if the mode is available, false otherwise. + */ + [[nodiscard]] bool hasMatchingMode(const MacDisplayModeList &modes, const MacDisplayMode &mode) { + return std::ranges::any_of(modes, [&mode](const auto &candidate) { + return mac_utils::fuzzyCompareModes(candidate, mode); + }); + } + + /** + * @brief Check whether a requested mode can be used for a display. + * @param api macOS API layer. + * @param display_id Display to inspect. + * @param current_mode Current display mode. + * @param requested_mode Requested display mode. + * @return True if the requested mode is current or available. + */ + [[nodiscard]] bool isRequestedModeAvailable( + const MacApiLayerInterface &api, + const MacDisplayId display_id, + const MacDisplayMode ¤t_mode, + const MacDisplayMode &requested_mode + ) { + return mac_utils::fuzzyCompareModes(current_mode, requested_mode) || hasMatchingMode(api.getDisplayModes(display_id), requested_mode); + } + + /** + * @brief Roll back previously changed display modes. + * @param display_device Display-device API used for rollback. + * @param changed_modes Original modes for changed devices. + */ + void rollbackChangedModes(MacDisplayDevice &display_device, const MacDeviceDisplayModeMap &changed_modes) { + if (!changed_modes.empty()) { + static_cast(display_device.setDisplayModes(changed_modes)); + } + } + } // namespace + + MacDeviceDisplayModeMap MacDisplayDevice::getCurrentDisplayModes(const StringSet &device_ids) const { + if (device_ids.empty()) { + DD_LOG(error) << "Device id set is empty!"; + return {}; + } + + MacDeviceDisplayModeMap current_modes; + for (const auto &device_id : device_ids) { + const auto display_id {getDisplayId(device_id, MacQueryType::Active)}; + if (!display_id.has_value()) { + DD_LOG(error) << "Failed to find active macOS display for " << device_id << "!"; + return {}; + } + + const auto current_mode {m_m_api->getCurrentDisplayMode(*display_id)}; + if (!current_mode) { + DD_LOG(error) << "Failed to get current macOS display mode for " << device_id << "!"; + return {}; + } + + current_modes[device_id] = *current_mode; + } + + return current_modes; + } + + bool MacDisplayDevice::setDisplayModes(const MacDeviceDisplayModeMap &modes) { + if (modes.empty()) { + DD_LOG(error) << "Modes map is empty!"; + return false; + } + + StringMap display_ids; + MacDeviceDisplayModeMap original_modes; + for (const auto &[device_id, mode] : modes) { + if (device_id.empty()) { + DD_LOG(error) << "Device id is empty!"; + return false; + } + + const auto display_id {getDisplayId(device_id, MacQueryType::Active)}; + if (!display_id.has_value()) { + DD_LOG(error) << "Failed to find active macOS display for " << device_id << "!"; + return false; + } + + const auto current_mode {m_m_api->getCurrentDisplayMode(*display_id)}; + if (!current_mode) { + DD_LOG(error) << "Failed to get current macOS display mode for " << device_id << "!"; + return false; + } + + if (!isRequestedModeAvailable(*m_m_api, *display_id, *current_mode, mode)) { + DD_LOG(error) << "Requested macOS display mode is not available for " << device_id << "!"; + return false; + } + + display_ids[device_id] = *display_id; + original_modes[device_id] = *current_mode; + } + + MacDeviceDisplayModeMap changed_modes; + for (const auto &[device_id, mode] : modes) { + if (mac_utils::fuzzyCompareModes(original_modes.at(device_id), mode)) { + continue; + } + + const auto display_id {display_ids.at(device_id)}; + if (!m_m_api->setDisplayMode(display_id, mode)) { + DD_LOG(error) << "Failed to set macOS display mode for " << device_id << "!"; + rollbackChangedModes(*this, changed_modes); + return false; + } + + if (const auto verified_mode {m_m_api->getCurrentDisplayMode(display_id)}; !verified_mode || !mac_utils::fuzzyCompareModes(*verified_mode, mode)) { + DD_LOG(error) << "Failed to verify macOS display mode for " << device_id << "!"; + changed_modes[device_id] = original_modes.at(device_id); + rollbackChangedModes(*this, changed_modes); + return false; + } + + changed_modes[device_id] = original_modes.at(device_id); + } + + if (changed_modes.empty()) { + DD_LOG(debug) << "No changes were made to macOS display modes as they are equal."; + } + + return true; + } +} // namespace display_device diff --git a/src/macos/mac_display_device_primary.cpp b/src/macos/mac_display_device_primary.cpp new file mode 100644 index 0000000..3cf7822 --- /dev/null +++ b/src/macos/mac_display_device_primary.cpp @@ -0,0 +1,18 @@ +/** + * @file src/macos/mac_display_device_primary.cpp + * @brief Definitions for primary display related methods in MacDisplayDevice. + */ +// class header include +#include "display_device/macos/mac_display_device.h" + +namespace display_device { + bool MacDisplayDevice::isPrimary(const std::string &device_id) const { + const auto display_id {getDisplayId(device_id, MacQueryType::Active)}; + return display_id && m_m_api->isMainDisplay(*display_id); + } + + bool MacDisplayDevice::setAsPrimary(const std::string &device_id) { + static_cast(device_id); + return false; + } +} // namespace display_device diff --git a/src/macos/mac_display_device_topology.cpp b/src/macos/mac_display_device_topology.cpp new file mode 100644 index 0000000..87d567c --- /dev/null +++ b/src/macos/mac_display_device_topology.cpp @@ -0,0 +1,85 @@ +/** + * @file src/macos/mac_display_device_topology.cpp + * @brief Definitions for topology related methods in MacDisplayDevice. + */ +// class header include +#include "display_device/macos/mac_display_device.h" + +// system includes +#include + +namespace display_device { + MacActiveTopology MacDisplayDevice::getCurrentTopology() const { + std::vector>> groups; + + for (const auto display_id : m_m_api->getDisplayIds(MacQueryType::Active)) { + const auto device_id {m_m_api->getDeviceId(display_id)}; + if (device_id.empty()) { + continue; + } + + const auto mirror_master {m_m_api->getMirrorMaster(display_id)}; + const auto group_key {mirror_master != 0 ? mirror_master : display_id}; + auto group_it {std::ranges::find_if(groups, [group_key](const auto &group) { + return group.first == group_key; + })}; + + if (group_it == std::end(groups)) { + groups.emplace_back(group_key, std::vector {device_id}); + } else { + group_it->second.push_back(device_id); + } + } + + MacActiveTopology topology; + topology.reserve(groups.size()); + for (auto &[group_key, device_ids] : groups) { + static_cast(group_key); + topology.push_back(std::move(device_ids)); + } + + return topology; + } + + bool MacDisplayDevice::isTopologyValid(const MacActiveTopology &topology) const { + if (topology.empty()) { + return false; + } + + StringUnorderedSet device_ids; + for (const auto &group : topology) { + if (group.empty()) { + return false; + } + + for (const auto &device_id : group) { + if (device_id.empty() || !device_ids.insert(device_id).second) { + return false; + } + } + } + + return true; + } + + bool MacDisplayDevice::isTopologyTheSame(const MacActiveTopology &lhs, const MacActiveTopology &rhs) const { + const auto sort_topology = [](MacActiveTopology &topology) { + for (auto &group : topology) { + std::ranges::sort(group); + } + + std::ranges::sort(topology); + }; + + auto lhs_copy {lhs}; + auto rhs_copy {rhs}; + sort_topology(lhs_copy); + sort_topology(rhs_copy); + return lhs_copy == rhs_copy; + } + + bool MacDisplayDevice::setTopology(const MacActiveTopology &new_topology) { + static_cast(new_topology); + return false; + } +} // namespace display_device diff --git a/src/macos/persistent_state.cpp b/src/macos/persistent_state.cpp new file mode 100644 index 0000000..2683627 --- /dev/null +++ b/src/macos/persistent_state.cpp @@ -0,0 +1,75 @@ +/** + * @file src/macos/persistent_state.cpp + * @brief Definitions for the MacPersistentState. + */ +// class header include +#include "display_device/macos/persistent_state.h" + +// system includes +#include + +// local includes +#include "display_device/detail/persistent_state_utils.h" +#include "display_device/logging.h" +#include "display_device/macos/json.h" +#include "display_device/noop_settings_persistence.h" + +namespace display_device { + namespace { + /** + * @brief Exception thrown when macOS persistent state loading fails. + */ + class MacPersistentStateLoadException final: public std::runtime_error { + public: + using std::runtime_error::runtime_error; + }; + } // namespace + + MacPersistentState::MacPersistentState(std::shared_ptr settings_persistence_api, const bool throw_on_load_error): + m_settings_persistence_api {std::move(settings_persistence_api)} { + if (!m_settings_persistence_api) { + m_settings_persistence_api = std::make_shared(); + } + + std::string error_message; + if (const auto persistent_settings {m_settings_persistence_api->load()}) { + if (!persistent_settings->empty()) { + m_cached_state = MacSingleDisplayConfigState {}; + if (!fromJson({std::begin(*persistent_settings), std::end(*persistent_settings)}, *m_cached_state, &error_message)) { + error_message = "Failed to parse macOS persistent settings! Error:\n" + error_message; + } + } + } else { + error_message = "Failed to load macOS persistent settings!"; + } + + if (!error_message.empty()) { + if (throw_on_load_error) { + throw MacPersistentStateLoadException {error_message}; + } + + DD_LOG(error) << error_message; + m_cached_state = std::nullopt; + } + } + + bool MacPersistentState::persistState(const std::optional &state) { + return detail::persistState( + *m_settings_persistence_api, + m_cached_state, + state, + [](const MacSingleDisplayConfigState &state_to_serialize, bool &success) { + return toJson(state_to_serialize, 2, &success); + }, + "Failed to serialize new macOS persistent state! Error:" + ); + } + + const std::optional &MacPersistentState::getState() const { + return m_cached_state; + } + + const std::shared_ptr &MacPersistentState::getSettingsPersistenceApi() const { + return m_settings_persistence_api; + } +} // namespace display_device diff --git a/src/macos/settings_manager_apply.cpp b/src/macos/settings_manager_apply.cpp new file mode 100644 index 0000000..4cb6fa8 --- /dev/null +++ b/src/macos/settings_manager_apply.cpp @@ -0,0 +1,307 @@ +/** + * @file src/macos/settings_manager_apply.cpp + * @brief Definitions for the methods for applying settings in MacSettingsManager. + */ +// class header include +#include "display_device/macos/settings_manager.h" + +// system includes +#include +#include +#include +#include + +// local includes +#include "display_device/logging.h" +#include "display_device/macos/json.h" +#include "display_device/macos/settings_utils.h" + +namespace display_device { + namespace { + /** + * @brief Check whether a device is active in the enumerated device list. + * @param devices Devices to inspect. + * @param device_id Device id to find. + * @return True if the device exists and is active. + */ + [[nodiscard]] bool isActiveDevice(const EnumeratedDeviceList &devices, const std::string &device_id) { + const auto device_it {std::ranges::find_if(devices, [&device_id](const auto &device) { + return device.m_device_id == device_id; + })}; + + return device_it != std::end(devices) && device_it->m_info.has_value(); + } + + /** + * @brief Find other devices in the same topology group as a target device. + * @param topology Topology to inspect. + * @param target_device_id Target device id. + * @return Devices from the same group except the target. + */ + [[nodiscard]] StringSet getOtherDevicesInTheSameGroup(const MacActiveTopology &topology, const std::string &target_device_id) { + StringSet device_ids; + for (const auto &group : topology) { + if (std::ranges::find(group, target_device_id) == std::end(group)) { + continue; + } + + std::ranges::copy_if(group, std::inserter(device_ids, std::begin(device_ids)), [&target_device_id](const auto &device_id) { + return device_id != target_device_id; + }); + break; + } + + return device_ids; + } + + /** + * @brief Create additional devices to configure for primary-device requests. + * @param primary_devices Primary devices from the initial state. + * @return Primary devices except the first one. + */ + [[nodiscard]] StringSet makeAdditionalPrimaryDevices(const StringSet &primary_devices) { + if (primary_devices.empty()) { + return {}; + } + + return {std::next(std::begin(primary_devices)), std::end(primary_devices)}; + } + + /** + * @brief Data prepared before applying macOS display settings. + */ + struct MacApplyPlan { + MacSingleDisplayConfigState m_state; ///< New persistence state. + std::string m_device_to_configure; ///< Device selected for configuration. + StringSet m_additional_devices_to_configure; ///< Additional devices affected by primary-device configuration. + MacDeviceDisplayModeMap m_cached_display_modes; ///< Original display modes from cached state. + bool m_configuring_primary_devices {}; ///< True when no explicit device was requested. + }; + + /** + * @brief Rollback data for display mode changes. + */ + struct ModeRollbackState { + bool m_required {}; ///< True when mode rollback should run on later failure. + MacDeviceDisplayModeMap m_modes; ///< Modes to restore. + }; + + /** + * @brief Build a set from mode map keys. + * @param modes Mode map to inspect. + * @return Set containing the mode map keys. + */ + [[nodiscard]] StringSet getModeKeys(const MacDeviceDisplayModeMap &modes) { + const auto mode_keys_view {std::views::keys(modes)}; + return {std::begin(mode_keys_view), std::end(mode_keys_view)}; + } + + /** + * @brief Prepare state and target metadata before applying settings. + * @param dd_api macOS display-device API. + * @param config Requested single-display configuration. + * @param cached_state Previously persisted state, if any. + * @return Prepared apply data, or empty optional on failure. + */ + [[nodiscard]] std::optional createApplyPlan( + const MacDisplayDeviceInterface &dd_api, + const SingleDisplayConfiguration &config, + const std::optional &cached_state + ) { + const auto topology_before_changes {dd_api.getCurrentTopology()}; + if (!dd_api.isTopologyValid(topology_before_changes)) { + DD_LOG(error) << "Retrieved current macOS topology is invalid:\n" + << toJson(topology_before_changes); + return std::nullopt; + } + + const auto devices {dd_api.enumAvailableDevices()}; + if (devices.empty()) { + DD_LOG(error) << "Failed to enumerate macOS display devices!"; + return std::nullopt; + } + + const auto new_initial_state {mac_utils::computeInitialState(cached_state ? std::make_optional(cached_state->m_initial) : std::nullopt, topology_before_changes, devices)}; + if (!new_initial_state) { + return std::nullopt; + } + + auto new_state {MacSingleDisplayConfigState {*new_initial_state}}; + const auto stripped_initial_state {mac_utils::stripInitialState(new_state.m_initial, devices)}; + if (!stripped_initial_state) { + return std::nullopt; + } + + const bool configuring_primary_devices {config.m_device_id.empty()}; + const auto device_to_configure {configuring_primary_devices ? *std::begin(stripped_initial_state->m_primary_devices) : config.m_device_id}; + const auto additional_devices_to_configure { + configuring_primary_devices ? makeAdditionalPrimaryDevices(stripped_initial_state->m_primary_devices) : getOtherDevicesInTheSameGroup(topology_before_changes, device_to_configure) + }; + + if (!isActiveDevice(devices, device_to_configure) || !mac_utils::flattenTopology(topology_before_changes).contains(device_to_configure)) { + DD_LOG(error) << "macOS device " << toJson(device_to_configure, JSON_COMPACT) << " is not active!"; + return std::nullopt; + } + + new_state.m_modified.m_topology = topology_before_changes; + return MacApplyPlan { + new_state, + device_to_configure, + additional_devices_to_configure, + cached_state ? cached_state->m_modified.m_original_modes : MacDeviceDisplayModeMap {}, + configuring_primary_devices + }; + } + + /** + * @brief Apply and verify display modes. + * @param dd_api macOS display-device API. + * @param current_modes Current display modes before the change. + * @param new_modes New display modes to apply. + * @param rollback_state Rollback state to update if modes changed. + * @return True if the change succeeds or no change is required. + */ + [[nodiscard]] bool changeDisplayModes( + MacDisplayDeviceInterface &dd_api, + const MacDeviceDisplayModeMap ¤t_modes, + const MacDeviceDisplayModeMap &new_modes, + ModeRollbackState &rollback_state + ) { + if (current_modes == new_modes) { + return true; + } + + DD_LOG(info) << "Changing macOS display modes to:\n" + << toJson(new_modes); + if (!dd_api.setDisplayModes(new_modes)) { + return false; + } + + const auto verified_modes {dd_api.getCurrentDisplayModes(getModeKeys(new_modes))}; + if (verified_modes.empty()) { + DD_LOG(error) << "Failed to verify changed macOS display modes!"; + static_cast(dd_api.setDisplayModes(current_modes)); + return false; + } + + if (current_modes != verified_modes) { + rollback_state.m_required = true; + rollback_state.m_modes = current_modes; + } + + return true; + } + + /** + * @brief Apply requested display mode changes. + * @param dd_api macOS display-device API. + * @param config Requested single-display configuration. + * @param current_modes Current display modes before the change. + * @param plan Prepared apply state to update. + * @param rollback_state Rollback state to update if modes changed. + * @return Apply result for the display-mode stage. + */ + [[nodiscard]] MacSettingsManager::ApplyResult applyRequestedModes( + MacDisplayDeviceInterface &dd_api, + const SingleDisplayConfiguration &config, + const MacDeviceDisplayModeMap ¤t_modes, + MacApplyPlan &plan, + ModeRollbackState &rollback_state + ) { + using enum SettingsManagerInterface::ApplyResult; + + const auto original_display_modes {plan.m_cached_display_modes.empty() ? current_modes : plan.m_cached_display_modes}; + if (const auto new_display_modes {mac_utils::computeNewDisplayModes(config.m_resolution, config.m_refresh_rate, plan.m_configuring_primary_devices, plan.m_device_to_configure, plan.m_additional_devices_to_configure, original_display_modes)}; !changeDisplayModes(dd_api, current_modes, new_display_modes, rollback_state)) { + DD_LOG(error) << "Failed to apply new macOS display modes!"; + return DisplayModePrepFailed; + } + + plan.m_state.m_modified.m_original_modes = original_display_modes; + return Ok; + } + + /** + * @brief Apply or restore display modes for an apply request. + * @param dd_api macOS display-device API. + * @param config Requested single-display configuration. + * @param plan Prepared apply state to update. + * @param rollback_state Rollback state to update if modes changed. + * @return Apply result for the display-mode stage. + */ + [[nodiscard]] MacSettingsManager::ApplyResult applyDisplayModes( + MacDisplayDeviceInterface &dd_api, + const SingleDisplayConfiguration &config, + MacApplyPlan &plan, + ModeRollbackState &rollback_state + ) { + using enum SettingsManagerInterface::ApplyResult; + + const bool change_required {config.m_resolution || config.m_refresh_rate}; + if (const bool might_need_to_restore {!plan.m_cached_display_modes.empty()}; !change_required && !might_need_to_restore) { + return Ok; + } + + const auto topology_devices {mac_utils::flattenTopology(plan.m_state.m_modified.m_topology)}; + const auto current_display_modes {dd_api.getCurrentDisplayModes(topology_devices)}; + if (current_display_modes.empty()) { + DD_LOG(error) << "Failed to get current macOS display modes!"; + return DisplayModePrepFailed; + } + + if (change_required) { + return applyRequestedModes(dd_api, config, current_display_modes, plan, rollback_state); + } + + if (!changeDisplayModes(dd_api, current_display_modes, plan.m_cached_display_modes, rollback_state)) { + DD_LOG(error) << "Failed to restore original macOS display modes!"; + return DisplayModePrepFailed; + } + + return Ok; + } + } // namespace + + MacSettingsManager::ApplyResult MacSettingsManager::applySettings(const SingleDisplayConfiguration &config) { + using enum SettingsManagerInterface::ApplyResult; + + const auto api_access {m_dd_api->isApiAccessAvailable()}; + DD_LOG(info) << "Trying to apply macOS display device settings. API is available: " << toJson(api_access); + + if (!api_access) { + return ApiTemporarilyUnavailable; + } + + DD_LOG(info) << "Using the following macOS configuration:\n" + << toJson(config); + + if (config.m_hdr_state) { + return HdrStatePrepFailed; + } + + if (config.m_device_prep != SingleDisplayConfiguration::DevicePreparation::VerifyOnly) { + DD_LOG(error) << "macOS phase 2 only supports VerifyOnly device preparation."; + return DevicePrepFailed; + } + + const auto &cached_state {m_persistence_state->getState()}; + auto apply_plan {createApplyPlan(*m_dd_api, config, cached_state)}; + if (!apply_plan) { + return DevicePrepFailed; + } + + ModeRollbackState mode_rollback; + if (const auto mode_result {applyDisplayModes(*m_dd_api, config, *apply_plan, mode_rollback)}; mode_result != Ok) { + return mode_result; + } + + if (!m_persistence_state->persistState(apply_plan->m_state)) { + DD_LOG(error) << "Failed to persist macOS display settings! Undoing changes..."; + if (mode_rollback.m_required) { + static_cast(m_dd_api->setDisplayModes(mode_rollback.m_modes)); + } + return PersistenceSaveFailed; + } + + return Ok; + } +} // namespace display_device diff --git a/src/macos/settings_manager_general.cpp b/src/macos/settings_manager_general.cpp new file mode 100644 index 0000000..002e3e8 --- /dev/null +++ b/src/macos/settings_manager_general.cpp @@ -0,0 +1,64 @@ +/** + * @file src/macos/settings_manager_general.cpp + * @brief Definitions for the leftover general methods in MacSettingsManager. + */ +// class header include +#include "display_device/macos/settings_manager.h" + +// system includes +#include + +// local includes +#include "display_device/noop_audio_context.h" + +namespace display_device { + MacSettingsManager::MacSettingsManager( + std::shared_ptr dd_api, + std::shared_ptr audio_context_api, + std::unique_ptr persistent_state, + MacWorkarounds workarounds + ): + m_dd_api {std::move(dd_api)}, + m_audio_context_api {std::move(audio_context_api)}, + m_persistence_state {std::move(persistent_state)}, + m_workarounds {std::move(workarounds)} { + if (!m_dd_api) { + throw std::invalid_argument {"Nullptr provided for MacDisplayDeviceInterface in MacSettingsManager!"}; + } + + if (!m_audio_context_api) { + m_audio_context_api = std::make_shared(); + } + + if (!m_persistence_state) { + throw std::invalid_argument {"Nullptr provided for MacPersistentState in MacSettingsManager!"}; + } + } + + EnumeratedDeviceList MacSettingsManager::enumAvailableDevices() const { + return m_dd_api->enumAvailableDevices(); + } + + std::string MacSettingsManager::getDisplayName(const std::string &device_id) const { + return m_dd_api->getDisplayName(device_id); + } + + const std::shared_ptr &MacSettingsManager::getAudioContextApi() const { + return m_audio_context_api; + } + + bool MacSettingsManager::resetPersistence() { + if (const auto &cached_state {m_persistence_state->getState()}; !cached_state) { + return true; + } + + if (!m_persistence_state->persistState(std::nullopt)) { + return false; + } + + if (m_audio_context_api->isCaptured()) { + m_audio_context_api->release(); + } + return true; + } +} // namespace display_device diff --git a/src/macos/settings_manager_revert.cpp b/src/macos/settings_manager_revert.cpp new file mode 100644 index 0000000..3678834 --- /dev/null +++ b/src/macos/settings_manager_revert.cpp @@ -0,0 +1,89 @@ +/** + * @file src/macos/settings_manager_revert.cpp + * @brief Definitions for the methods for reverting settings in MacSettingsManager. + */ +// class header include +#include "display_device/macos/settings_manager.h" + +// local includes +#include "display_device/logging.h" +#include "display_device/macos/json.h" +#include "display_device/macos/settings_utils.h" + +namespace display_device { + MacSettingsManager::RevertResult MacSettingsManager::revertSettings() { + const auto &cached_state {m_persistence_state->getState()}; + if (!cached_state) { + return RevertResult::Ok; + } + + if (!m_dd_api->isApiAccessAvailable()) { + return RevertResult::ApiTemporarilyUnavailable; + } + + const auto current_topology {m_dd_api->getCurrentTopology()}; + if (!m_dd_api->isTopologyValid(current_topology)) { + DD_LOG(error) << "Retrieved current macOS topology is invalid:\n" + << toJson(current_topology); + return RevertResult::TopologyIsInvalid; + } + + if (!m_dd_api->isTopologyValid(cached_state->m_modified.m_topology)) { + DD_LOG(error) << "Trying to revert macOS modes using invalid modified topology:\n" + << toJson(cached_state->m_modified.m_topology); + return RevertResult::TopologyIsInvalid; + } + + if (!m_dd_api->isTopologyTheSame(current_topology, cached_state->m_modified.m_topology)) { + DD_LOG(error) << "Cannot revert macOS display modes because topology changes are not supported in phase 2."; + return RevertResult::SwitchingTopologyFailed; + } + + bool rollback_modes_on_failure {false}; + MacDeviceDisplayModeMap modes_to_restore; + + if (!cached_state->m_modified.m_original_hdr_states.empty()) { + DD_LOG(error) << "Cannot revert macOS HDR state because HDR mutations are unsupported."; + return RevertResult::RevertingHdrStatesFailed; + } + + if (!cached_state->m_modified.m_original_primary_device.empty()) { + DD_LOG(error) << "Cannot revert macOS primary display because primary mutations are unsupported in phase 2."; + return RevertResult::RevertingPrimaryDeviceFailed; + } + + if (!cached_state->m_modified.m_original_modes.empty()) { + const auto mode_devices {mac_utils::flattenTopology(cached_state->m_modified.m_topology)}; + const auto current_modes {m_dd_api->getCurrentDisplayModes(mode_devices)}; + if (current_modes.empty()) { + DD_LOG(error) << "Failed to get current macOS display modes for revert!"; + return RevertResult::RevertingDisplayModesFailed; + } + + if (current_modes != cached_state->m_modified.m_original_modes) { + DD_LOG(info) << "Trying to change back macOS display modes to:\n" + << toJson(cached_state->m_modified.m_original_modes); + if (!m_dd_api->setDisplayModes(cached_state->m_modified.m_original_modes)) { + return RevertResult::RevertingDisplayModesFailed; + } + + rollback_modes_on_failure = true; + modes_to_restore = current_modes; + } + } + + if (!m_persistence_state->persistState(std::nullopt)) { + DD_LOG(error) << "Failed to clear reverted macOS display settings! Undoing mode changes..."; + if (rollback_modes_on_failure) { + static_cast(m_dd_api->setDisplayModes(modes_to_restore)); + } + return RevertResult::PersistenceSaveFailed; + } + + if (m_audio_context_api->isCaptured()) { + m_audio_context_api->release(); + } + + return RevertResult::Ok; + } +} // namespace display_device diff --git a/src/macos/settings_utils.cpp b/src/macos/settings_utils.cpp new file mode 100644 index 0000000..9179f2c --- /dev/null +++ b/src/macos/settings_utils.cpp @@ -0,0 +1,193 @@ +/** + * @file src/macos/settings_utils.cpp + * @brief Definitions for macOS settings utility functions. + */ +// header include +#include "display_device/macos/settings_utils.h" + +// system includes +#include +#include +#include +#include +#include +#include + +// local includes +#include "display_device/detail/settings_state_utils.h" +#include "display_device/logging.h" +#include "display_device/macos/json.h" + +namespace display_device::mac_utils { + namespace { + /** + * @brief Predicate that accepts any device. + * @param device Device to check. + * @return Always true. + */ + [[nodiscard]] bool anyDevice(const EnumeratedDevice &device) { + static_cast(device); + return true; + } + + /** + * @brief Predicate that accepts primary active devices. + * @param device Device to check. + * @return True if the device is active and primary. + */ + [[nodiscard]] bool primaryOnlyDevices(const EnumeratedDevice &device) { + return device.m_info && device.m_info->m_primary; + } + + /** + * @brief Get device ids from devices matching a predicate. + * @param devices Device list to inspect. + * @param predicate Predicate to match. + * @return Matching device ids. + */ + [[nodiscard]] StringSet getDeviceIds(const EnumeratedDeviceList &devices, std::add_lvalue_reference_t &predicate) { + StringSet device_ids; + for (const auto &device : devices) { + if (predicate(device)) { + device_ids.insert(device.m_device_id); + } + } + + return device_ids; + } + + /** + * @brief Merge the primary and additional devices into one ordered list. + * @param device_to_configure Primary device to configure. + * @param additional_devices_to_configure Additional devices to configure. + * @return Ordered device id list. + */ + [[nodiscard]] std::vector joinConfigurableDevices(const std::string &device_to_configure, const StringSet &additional_devices_to_configure) { + std::vector devices {device_to_configure}; + devices.insert(std::end(devices), std::begin(additional_devices_to_configure), std::end(additional_devices_to_configure)); + return devices; + } + + /** + * @brief Convert a floating-point setting to a rational refresh rate. + * @param value Floating-point setting. + * @return Rational refresh rate. + */ + [[nodiscard]] Rational fromFloatingPoint(const FloatingPoint &value) { + if (const auto *rational_value {std::get_if(&value)}; rational_value) { + return *rational_value; + } + + constexpr unsigned int multiplier {10000}; + const auto transformed_value {std::round(std::get(value) * multiplier)}; + return Rational {static_cast(transformed_value), multiplier}; + } + } // namespace + + StringSet flattenTopology(const MacActiveTopology &topology) { + StringSet flattened_topology; + for (const auto &group : topology) { + for (const auto &device_id : group) { + flattened_topology.insert(device_id); + } + } + + return flattened_topology; + } + + std::string getPrimaryDevice(const MacDisplayDeviceInterface &mac_dd, const MacActiveTopology &topology) { + for (const auto &device_id : flattenTopology(topology)) { + if (mac_dd.isPrimary(device_id)) { + return device_id; + } + } + + return {}; + } + + std::optional computeInitialState( + const std::optional &prev_state, + const MacActiveTopology &topology_before_changes, + const EnumeratedDeviceList &devices + ) { + if (prev_state) { + return *prev_state; + } + + const auto primary_devices {getDeviceIds(devices, primaryOnlyDevices)}; + if (primary_devices.empty()) { + DD_LOG(error) << "Enumerated macOS device list does not contain primary devices!"; + return std::nullopt; + } + + return MacSingleDisplayConfigState::Initial { + topology_before_changes, + primary_devices + }; + } + + std::optional stripInitialState( + const MacSingleDisplayConfigState::Initial &initial_state, + const EnumeratedDeviceList &devices + ) { + return detail::stripInitialState( + initial_state, + getDeviceIds(devices, anyDevice), + getDeviceIds(devices, primaryOnlyDevices), + detail::InitialStateStripMessages { + "Enumerated macOS device list does not contain any device from the initial state!", + "Enumerated macOS device list does not contain primary devices!", + "Adapting macOS initial state because some devices are unavailable.", + }, + [](const MacActiveTopology &topology) { + return toJson(topology, JSON_COMPACT); + } + ); + } + + MacDeviceDisplayModeMap computeNewDisplayModes( + const std::optional &resolution, + const std::optional &refresh_rate, + const bool configuring_primary_devices, + const std::string &device_to_configure, + const StringSet &additional_devices_to_configure, + const MacDeviceDisplayModeMap &original_modes + ) { + MacDeviceDisplayModeMap new_modes {original_modes}; + + if (resolution) { + const auto devices {joinConfigurableDevices(device_to_configure, additional_devices_to_configure)}; + for (const auto &device_id : devices) { + new_modes[device_id].m_resolution = *resolution; + } + } + + if (refresh_rate) { + if (configuring_primary_devices) { + const auto devices {joinConfigurableDevices(device_to_configure, additional_devices_to_configure)}; + for (const auto &device_id : devices) { + new_modes[device_id].m_refresh_rate = fromFloatingPoint(*refresh_rate); + } + } else { + new_modes[device_to_configure].m_refresh_rate = fromFloatingPoint(*refresh_rate); + } + } + + return new_modes; + } + + MacDdGuardFn modeGuardFn(MacDisplayDeviceInterface &mac_dd, const MacDeviceDisplayModeMap &modes) { + DD_LOG(debug) << "Got macOS modes in modeGuardFn:\n" + << toJson(modes); + return [&mac_dd, modes]() { + if (!mac_dd.setDisplayModes(modes)) { + DD_LOG(error) << "Failed to revert macOS display modes in modeGuardFn! Used the following modes:\n" + << toJson(modes); + } + }; + } + + void noopGuard() { + // Intentionally empty guard callback. + } +} // namespace display_device::mac_utils diff --git a/src/macos/types.cpp b/src/macos/types.cpp new file mode 100644 index 0000000..4c94be4 --- /dev/null +++ b/src/macos/types.cpp @@ -0,0 +1,12 @@ +/** + * @file src/macos/types.cpp + * @brief Definitions for macOS specific types. + */ +// header include +#include "display_device/macos/types.h" + +namespace display_device { + bool MacSingleDisplayConfigState::Modified::hasModifications() const { + return !m_original_modes.empty() || !m_original_hdr_states.empty() || !m_original_primary_device.empty(); + } +} // namespace display_device diff --git a/src/windows/display_power.cpp b/src/windows/display_power.cpp new file mode 100644 index 0000000..4c498e3 --- /dev/null +++ b/src/windows/display_power.cpp @@ -0,0 +1,63 @@ +/** + * @file src/windows/display_power.cpp + * @brief Definitions for Windows display power management. + */ +// class header include +#include "display_device/windows/display_power.h" + +// system includes +#include +#include + +namespace display_device { + namespace { + /** + * @brief Scoped Windows display power request. + */ + class WinDisplayPowerGuard: public DisplayPowerGuardInterface { + public: + /** + * @brief Constructor. + * @param w_api Windows API layer. + */ + explicit WinDisplayPowerGuard(std::shared_ptr w_api): + m_w_api {std::move(w_api)} {} + + WinDisplayPowerGuard(const WinDisplayPowerGuard &) = delete; ///< Copy constructor. + WinDisplayPowerGuard &operator=(const WinDisplayPowerGuard &) = delete; ///< Copy assignment operator. + WinDisplayPowerGuard(WinDisplayPowerGuard &&) = delete; ///< Move constructor. + WinDisplayPowerGuard &operator=(WinDisplayPowerGuard &&) = delete; ///< Move assignment operator. + + /** + * @brief Destructor. + */ + ~WinDisplayPowerGuard() override { + static_cast(m_w_api->restorePowerRequest()); + } + + private: + std::shared_ptr m_w_api; ///< Windows API layer. + }; + } // namespace + + WinDisplayPower::WinDisplayPower(std::shared_ptr w_api): + m_w_api {std::move(w_api)} { + if (!m_w_api) { + throw std::invalid_argument {"Nullptr provided for WinApiLayerInterface in WinDisplayPower!"}; + } + } + + bool WinDisplayPower::wakeDisplay(const std::string &display_name, const std::chrono::milliseconds timeout) { + static_cast(display_name); + return m_w_api->wakeDisplay(timeout); + } + + std::unique_ptr WinDisplayPower::keepDisplayAwake(const std::string &reason) { + static_cast(reason); + if (!m_w_api->keepDisplayAwake()) { + return nullptr; + } + + return std::make_unique(m_w_api); + } +} // namespace display_device diff --git a/src/windows/include/display_device/windows/display_power.h b/src/windows/include/display_device/windows/display_power.h new file mode 100644 index 0000000..02299f3 --- /dev/null +++ b/src/windows/include/display_device/windows/display_power.h @@ -0,0 +1,39 @@ +/** + * @file src/windows/include/display_device/windows/display_power.h + * @brief Declarations for Windows display power management. + */ +#pragma once + +// system includes +#include + +// local includes +#include "display_device/display_power_interface.h" +#include "win_api_layer_interface.h" + +namespace display_device { + /** + * @brief Windows implementation of DisplayPowerInterface. + */ + class WinDisplayPower: public DisplayPowerInterface { + public: + /** + * @brief Default constructor for the class. + * @param w_api A pointer to the Windows API layer. Will throw on nullptr. + */ + explicit WinDisplayPower(std::shared_ptr w_api); + + /** + * @copydoc DisplayPowerInterface::wakeDisplay + */ + [[nodiscard]] bool wakeDisplay(const std::string &display_name, std::chrono::milliseconds timeout) override; + + /** + * @copydoc DisplayPowerInterface::keepDisplayAwake + */ + [[nodiscard]] std::unique_ptr keepDisplayAwake(const std::string &reason) override; + + private: + std::shared_ptr m_w_api; ///< Windows API layer. + }; +} // namespace display_device diff --git a/src/windows/include/display_device/windows/win_api_layer.h b/src/windows/include/display_device/windows/win_api_layer.h index ef8c273..d2a1efd 100644 --- a/src/windows/include/display_device/windows/win_api_layer.h +++ b/src/windows/include/display_device/windows/win_api_layer.h @@ -26,6 +26,21 @@ namespace display_device { */ [[nodiscard]] std::optional queryDisplayConfig(QueryType type) const override; + /** + * @copydoc WinApiLayerInterface::wakeDisplay + */ + [[nodiscard]] bool wakeDisplay(std::chrono::milliseconds timeout) override; + + /** + * @copydoc WinApiLayerInterface::keepDisplayAwake + */ + [[nodiscard]] bool keepDisplayAwake() override; + + /** + * @copydoc WinApiLayerInterface::restorePowerRequest + */ + [[nodiscard]] bool restorePowerRequest() override; + /** * @copydoc WinApiLayerInterface::getDeviceId */ diff --git a/src/windows/include/display_device/windows/win_api_layer_interface.h b/src/windows/include/display_device/windows/win_api_layer_interface.h index a40862c..d1fe0b1 100644 --- a/src/windows/include/display_device/windows/win_api_layer_interface.h +++ b/src/windows/include/display_device/windows/win_api_layer_interface.h @@ -43,6 +43,29 @@ namespace display_device { */ [[nodiscard]] virtual std::optional queryDisplayConfig(QueryType type) const = 0; + /** + * @brief Ask Windows to wake the display and wait before retrying detection. + * + * Windows accepts a display-required execution-state request, but this low-level + * call does not prove that a specific output is active afterward. + * + * @param timeout Maximum time to wait after issuing the wake request. + * @returns True if Windows accepted the wake request, false otherwise. + */ + [[nodiscard]] virtual bool wakeDisplay(std::chrono::milliseconds timeout) = 0; + + /** + * @brief Request that Windows keep the current thread's display awake. + * @returns True if Windows accepted the keep-awake request, false otherwise. + */ + [[nodiscard]] virtual bool keepDisplayAwake() = 0; + + /** + * @brief Clear the current thread's display keep-awake request. + * @returns True if Windows accepted the restore request, false otherwise. + */ + [[nodiscard]] virtual bool restorePowerRequest() = 0; + /** * @brief Get a stable and persistent device id for the path. * diff --git a/src/windows/persistent_state.cpp b/src/windows/persistent_state.cpp index d931a8f..aca63cd 100644 --- a/src/windows/persistent_state.cpp +++ b/src/windows/persistent_state.cpp @@ -9,6 +9,7 @@ #include // local includes +#include "display_device/detail/persistent_state_utils.h" #include "display_device/logging.h" #include "display_device/noop_settings_persistence.h" #include "display_device/windows/json.h" @@ -50,33 +51,15 @@ namespace display_device { } bool PersistentState::persistState(const std::optional &state) { - if (m_cached_state == state) { - return true; - } - - if (!state) { - if (!m_settings_persistence_api->clear()) { - return false; - } - - m_cached_state = std::nullopt; - return true; - } - - bool success {false}; - const auto json_string {toJson(*state, 2, &success)}; - if (!success) { - DD_LOG(error) << "Failed to serialize new persistent state! Error:\n" - << json_string; - return false; - } - - if (!m_settings_persistence_api->store({std::begin(json_string), std::end(json_string)})) { - return false; - } - - m_cached_state = *state; - return true; + return detail::persistState( + *m_settings_persistence_api, + m_cached_state, + state, + [](const SingleDisplayConfigState &state_to_serialize, bool &success) { + return toJson(state_to_serialize, 2, &success); + }, + "Failed to serialize new persistent state! Error:" + ); } const std::optional &PersistentState::getState() const { diff --git a/src/windows/settings_utils.cpp b/src/windows/settings_utils.cpp index e355875..a2798bc 100644 --- a/src/windows/settings_utils.cpp +++ b/src/windows/settings_utils.cpp @@ -11,6 +11,7 @@ #include // local includes +#include "display_device/detail/settings_state_utils.h" #include "display_device/logging.h" #include "display_device/windows/json.h" @@ -53,46 +54,6 @@ namespace display_device::win_utils { return device_ids; } - /** - * @brief Remove the topology device ids and groups that no longer have valid devices. - * @param topology Topology to be stripped. - * @param devices List of devices. - * @return Topology without missing device ids. - */ - ActiveTopology stripTopology(const ActiveTopology &topology, const EnumeratedDeviceList &devices) { - const StringSet available_device_ids {getDeviceIds(devices, anyDevice)}; - - ActiveTopology stripped_topology; - for (const auto &group : topology) { - std::vector stripped_group; - for (const auto &device_id : group) { - if (available_device_ids.contains(device_id)) { - stripped_group.push_back(device_id); - } - } - - if (!stripped_group.empty()) { - stripped_topology.push_back(stripped_group); - } - } - - return stripped_topology; - } - - /** - * @brief Remove device ids that are no longer available. - * @param device_ids Id list to be stripped. - * @param devices List of devices. - * @return List without missing device ids. - */ - StringSet stripDevices(const StringSet &device_ids, const EnumeratedDeviceList &devices) { - StringSet available_device_ids {getDeviceIds(devices, anyDevice)}; - - StringSet available_devices; - std::ranges::set_intersection(device_ids, available_device_ids, std::inserter(available_devices, std::begin(available_devices))); - return available_devices; - } - /** * @brief Find topology group with matching id and get other ids from the group. * @param topology Topology to be searched. @@ -218,35 +179,20 @@ namespace display_device::win_utils { } std::optional stripInitialState(const SingleDisplayConfigState::Initial &initial_state, const EnumeratedDeviceList &devices) { - const auto stripped_initial_topology {stripTopology(initial_state.m_topology, devices)}; - auto initial_primary_devices {stripDevices(initial_state.m_primary_devices, devices)}; - - if (stripped_initial_topology.empty()) { - DD_LOG(error) << "Enumerated device list does not contain ANY of the devices from the initial state!"; - return std::nullopt; - } - - if (initial_primary_devices.empty()) { - // The initial primay device is no longer available, so maybe it makes sense to use the current one. Maybe... - initial_primary_devices = getDeviceIds(devices, primaryOnlyDevices); - if (initial_primary_devices.empty()) { - DD_LOG(error) << "Enumerated device list does not contain primary devices!"; - return std::nullopt; + return detail::stripInitialState( + initial_state, + getDeviceIds(devices, anyDevice), + getDeviceIds(devices, primaryOnlyDevices), + detail::InitialStateStripMessages { + "Enumerated device list does not contain ANY of the devices from the initial state!", + "Enumerated device list does not contain primary devices!", + "Trying to apply configuration without reverting back to initial topology first, however not all devices from that topology are available.\n" + "Will try adapting the initial topology that is used as a base:", + }, + [](const ActiveTopology &topology) { + return toJson(topology, JSON_COMPACT); } - } - - if (initial_state.m_topology != stripped_initial_topology || initial_state.m_primary_devices != initial_primary_devices) { - DD_LOG(warning) << "Trying to apply configuration without reverting back to initial topology first, however not all devices from that " - "topology are available.\n" - << "Will try adapting the initial topology that is used as a base:\n" - << " - topology: " << toJson(initial_state.m_topology, JSON_COMPACT) << " -> " << toJson(stripped_initial_topology, JSON_COMPACT) << "\n" - << " - primary devices: " << toJson(initial_state.m_primary_devices, JSON_COMPACT) << " -> " << toJson(initial_primary_devices, JSON_COMPACT); - } - - return SingleDisplayConfigState::Initial { - stripped_initial_topology, - initial_primary_devices - }; + ); } std::tuple computeNewTopologyAndMetadata(const SingleDisplayConfiguration::DevicePreparation device_prep, const std::string &device_id, const SingleDisplayConfigState::Initial &initial_state) { diff --git a/src/windows/win_api_layer.cpp b/src/windows/win_api_layer.cpp index c6561ca..f482e79 100644 --- a/src/windows/win_api_layer.cpp +++ b/src/windows/win_api_layer.cpp @@ -6,6 +6,7 @@ #include "display_device/windows/win_api_layer.h" // system includes +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -527,6 +529,38 @@ namespace display_device { return PathAndModeData {paths, modes}; } + bool WinApiLayer::wakeDisplay(const std::chrono::milliseconds timeout) { + if (const auto result {SetThreadExecutionState(ES_DISPLAY_REQUIRED)}; result == 0) { + DD_LOG(error) << getErrorString(static_cast(GetLastError())) << " failed to wake display."; + return false; + } + + if (timeout.count() > 0) { + const auto wait_time {std::min(timeout.count(), std::numeric_limits::max())}; + Sleep(static_cast(wait_time)); + } + + return true; + } + + bool WinApiLayer::keepDisplayAwake() { + if (const auto result {SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED)}; result == 0) { + DD_LOG(error) << getErrorString(static_cast(GetLastError())) << " failed to request display keep-awake."; + return false; + } + + return true; + } + + bool WinApiLayer::restorePowerRequest() { + if (const auto result {SetThreadExecutionState(ES_CONTINUOUS)}; result == 0) { + DD_LOG(error) << getErrorString(static_cast(GetLastError())) << " failed to restore display power request."; + return false; + } + + return true; + } + std::string WinApiLayer::getDeviceId(const DISPLAYCONFIG_PATH_INFO &path) const { const auto device_path {getMonitorDevicePathWstr(*this, path)}; if (device_path.empty()) { diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 20e84f5..bbf13e1 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -5,7 +5,7 @@ add_subdirectory(general) if(WIN32) add_subdirectory(windows) elseif(APPLE) - message(WARNING "MacOS is not supported yet.") + add_subdirectory(macos) elseif(UNIX) message(WARNING "Linux is not supported yet.") else() diff --git a/tests/unit/macos/CMakeLists.txt b/tests/unit/macos/CMakeLists.txt new file mode 100644 index 0000000..1e978fb --- /dev/null +++ b/tests/unit/macos/CMakeLists.txt @@ -0,0 +1,5 @@ +# Add the test files in this directory +add_dd_test_dir( + ADDITIONAL_SOURCES + utils/*.h +) diff --git a/tests/unit/macos/test_display_power.cpp b/tests/unit/macos/test_display_power.cpp new file mode 100644 index 0000000..58f6df8 --- /dev/null +++ b/tests/unit/macos/test_display_power.cpp @@ -0,0 +1,123 @@ +// system includes +#include +#include +#include + +// local includes +#include "display_device/macos/display_power.h" +#include "fixtures/fixtures.h" +#include "utils/mock_mac_api_layer.h" + +namespace { + using namespace std::chrono_literals; + + // Convenience keywords for GMock + using ::testing::HasSubstr; + using ::testing::InSequence; + using ::testing::Return; + using ::testing::StrictMock; + + // Test fixture(s) for this file + class MacDisplayPowerTest: public BaseTest { + public: + std::shared_ptr> m_layer {std::make_shared>()}; + display_device::MacDisplayPower m_power {m_layer}; + }; + + // Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, MacDisplayPowerTest, __VA_ARGS__) +} // namespace + +TEST_F_S(NullptrLayerProvided) { + EXPECT_THAT([]() { + const auto power {display_device::MacDisplayPower {nullptr}}; + }, + ThrowsMessage(HasSubstr("Nullptr provided for MacApiLayerInterface in MacDisplayPower!"))); +} + +TEST_F_S(KeepDisplayAwakeCreatesAndReleasesAssertion) { + InSequence sequence; + EXPECT_CALL(*m_layer, createDisplaySleepAssertion("Test capture")) + .Times(1) + .WillOnce(Return(display_device::MacPowerAssertionId {42})); + EXPECT_CALL(*m_layer, releasePowerAssertion(42)) + .Times(1) + .WillOnce(Return(true)); + + { + const auto guard {m_power.keepDisplayAwake("Test capture")}; + ASSERT_NE(guard, nullptr); + } +} + +TEST_F_S(KeepDisplayAwakeReturnsNullptrWhenAssertionFails) { + EXPECT_CALL(*m_layer, createDisplaySleepAssertion("Test capture")) + .Times(1) + .WillOnce(Return(std::nullopt)); + + EXPECT_EQ(m_power.keepDisplayAwake("Test capture"), nullptr); +} + +TEST_F_S(WakeDisplayReturnsTrueWhenRequestedDisplayIsAlreadyActive) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {42})); + + EXPECT_TRUE(m_power.wakeDisplay("42", 0ms)); +} + +TEST_F_S(WakeDisplayUsesAnyActiveDisplayForNonNumericSelector) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {42})); + + EXPECT_TRUE(m_power.wakeDisplay("Built-in Display", 0ms)); +} + +TEST_F_S(WakeDisplayDeclaresUserActivityAndReleasesAssertion) { + InSequence sequence; + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {})); + EXPECT_CALL(*m_layer, declareUserActivity("libdisplaydevice display detection")) + .Times(1) + .WillOnce(Return(display_device::MacPowerAssertionId {7})); + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {42})); + EXPECT_CALL(*m_layer, releasePowerAssertion(7)) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_TRUE(m_power.wakeDisplay("42", 0ms)); +} + +TEST_F_S(WakeDisplayReturnsFalseWhenAssertionFails) { + InSequence sequence; + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {})); + EXPECT_CALL(*m_layer, declareUserActivity("libdisplaydevice display detection")) + .Times(1) + .WillOnce(Return(std::nullopt)); + + EXPECT_FALSE(m_power.wakeDisplay("42", 0ms)); +} + +TEST_F_S(WakeDisplayReturnsFalseAfterTimeoutAndReleasesAssertion) { + InSequence sequence; + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {})); + EXPECT_CALL(*m_layer, declareUserActivity("libdisplaydevice display detection")) + .Times(1) + .WillOnce(Return(display_device::MacPowerAssertionId {7})); + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(2) + .WillRepeatedly(Return(display_device::MacDisplayIdList {})); + EXPECT_CALL(*m_layer, releasePowerAssertion(7)) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_FALSE(m_power.wakeDisplay("42", 0ms)); +} diff --git a/tests/unit/macos/test_json_converter.cpp b/tests/unit/macos/test_json_converter.cpp new file mode 100644 index 0000000..099dcd0 --- /dev/null +++ b/tests/unit/macos/test_json_converter.cpp @@ -0,0 +1,57 @@ +// local includes +#include "display_device/macos/json.h" +#include "fixtures/json_converter_test.h" + +// Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, JsonConverterTest, __VA_ARGS__) + +TEST_F_S(MacActiveTopology) { + executeTestCase(display_device::MacActiveTopology {}, R"([])"); + executeTestCase(display_device::MacActiveTopology {{"DeviceId1"}, {"DeviceId2", "DeviceId3"}, {"DeviceId4"}}, R"([["DeviceId1"],["DeviceId2","DeviceId3"],["DeviceId4"]])"); + executeInvalidJsonTestCase(); + executeFromJsonFailureTestCase(R"([{}])"); + executeToJsonFailureTestCase(display_device::MacActiveTopology {{"DeviceId1\xC2"}}); +} + +TEST_F_S(MacDeviceDisplayModeMap) { + executeTestCase(display_device::MacDeviceDisplayModeMap {}, R"({})"); + executeTestCase( + display_device::MacDeviceDisplayModeMap {{"DeviceId1", {}}, {"DeviceId2", {{1920, 1080}, {120, 1}}}}, + R"({"DeviceId1":{"refresh_rate":{"denominator":0,"numerator":0},"resolution":{"height":0,"width":0}},"DeviceId2":{"refresh_rate":{"denominator":1,"numerator":120},"resolution":{"height":1080,"width":1920}}})" + ); + executeInvalidJsonTestCase(); + executeFromJsonFailureTestCase(R"({"DeviceId1":{}})"); + executeToJsonFailureTestCase(display_device::MacDeviceDisplayModeMap {{"DeviceId1\xC2", {}}}); +} + +TEST_F_S(MacHdrStateMap) { + executeTestCase(display_device::MacHdrStateMap {}, R"({})"); + executeTestCase(display_device::MacHdrStateMap {{"DeviceId1", std::nullopt}, {"DeviceId2", display_device::HdrState::Enabled}}, R"({"DeviceId1":null,"DeviceId2":"Enabled"})"); + executeInvalidJsonTestCase(); + executeFromJsonFailureTestCase(R"({"DeviceId1":"OtherValue"})"); + executeToJsonFailureTestCase(display_device::MacHdrStateMap {{"DeviceId1\xC2", std::nullopt}}); + executeToJsonFailureTestCase(display_device::MacHdrStateMap {{"DeviceId1", static_cast(-1)}}); +} + +TEST_F_S(MacSingleDisplayConfigState) { + const display_device::MacSingleDisplayConfigState valid_input { + {{{"DeviceId1"}}, + {"DeviceId1"}}, + {display_device::MacSingleDisplayConfigState::Modified { + {{"DeviceId2"}}, + {{"DeviceId2", {{1920, 1080}, {120, 1}}}}, + {{"DeviceId2", {display_device::HdrState::Disabled}}}, + {"DeviceId2"}, + }} + }; + + executeTestCase(display_device::MacSingleDisplayConfigState {}, R"({"initial":{"primary_devices":[],"topology":[]},"modified":{"original_hdr_states":{},"original_modes":{},"original_primary_device":"","topology":[]}})"); + executeTestCase(valid_input, R"({"initial":{"primary_devices":["DeviceId1"],"topology":[["DeviceId1"]]},"modified":{"original_hdr_states":{"DeviceId2":"Disabled"},"original_modes":{"DeviceId2":{"refresh_rate":{"denominator":1,"numerator":120},"resolution":{"height":1080,"width":1920}}},"original_primary_device":"DeviceId2","topology":[["DeviceId2"]]}})"); + executeInvalidJsonTestCase(); + executeFromJsonFailureTestCase(R"({})"); + executeToJsonFailureTestCase(display_device::MacSingleDisplayConfigState { + .m_modified = { + .m_original_primary_device = "DeviceId1\xC2", + }, + }); +} diff --git a/tests/unit/macos/test_mac_api_layer.cpp b/tests/unit/macos/test_mac_api_layer.cpp new file mode 100644 index 0000000..526c6e7 --- /dev/null +++ b/tests/unit/macos/test_mac_api_layer.cpp @@ -0,0 +1,77 @@ +// system includes +#include +#include +#include + +// local includes +#include "display_device/macos/mac_api_layer.h" +#include "fixtures/fixtures.h" + +namespace { + // Convenience keywords for GMock + using ::testing::HasSubstr; + + // Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, MacApiLayer, __VA_ARGS__) + + // Test fixture(s) for this file + class MacApiLayer: public BaseTest { + public: + display_device::MacApiLayer m_layer; + }; +} // namespace + +TEST_F_S(IsApiAccessAvailable) { + EXPECT_TRUE(m_layer.isApiAccessAvailable()); +} + +TEST_F_S(GetErrorString) { + EXPECT_THAT(m_layer.getErrorString(0), HasSubstr("Success")); + EXPECT_THAT(m_layer.getErrorString(7), HasSubstr("Unknown CoreGraphics error")); +} + +TEST_F_S(GetDisplayIds) { + const auto active_displays {m_layer.getDisplayIds(display_device::MacQueryType::Active)}; + const auto online_displays {m_layer.getDisplayIds(display_device::MacQueryType::Online)}; + + ASSERT_FALSE(active_displays.empty()); + EXPECT_GE(online_displays.size(), active_displays.size()); +} + +TEST_F_S(QueryActiveDisplay) { + const auto active_displays {m_layer.getDisplayIds(display_device::MacQueryType::Active)}; + ASSERT_FALSE(active_displays.empty()); + + const auto display_id {active_displays.front()}; + EXPECT_FALSE(m_layer.getDeviceId(display_id).empty()); + EXPECT_EQ(m_layer.getDisplayName(display_id), std::to_string(display_id)); + EXPECT_TRUE(m_layer.isActive(display_id)); + EXPECT_TRUE(m_layer.isOnline(display_id)); + + const auto current_mode {m_layer.getCurrentDisplayMode(display_id)}; + ASSERT_TRUE(current_mode); + EXPECT_GT(current_mode->m_resolution.m_width, 0U); + EXPECT_GT(current_mode->m_resolution.m_height, 0U); + EXPECT_GT(current_mode->m_refresh_rate.m_denominator, 0U); + + const auto origin {m_layer.getOriginPoint(display_id)}; + EXPECT_TRUE(origin); + + const auto modes {m_layer.getDisplayModes(display_id)}; + EXPECT_TRUE(std::ranges::find(modes, *current_mode) != std::end(modes) || !modes.empty()); +} + +TEST_F_S(HasMainDisplay) { + const auto active_displays {m_layer.getDisplayIds(display_device::MacQueryType::Active)}; + ASSERT_FALSE(active_displays.empty()); + + EXPECT_TRUE(std::ranges::any_of(active_displays, [this](const auto display_id) { + return m_layer.isMainDisplay(display_id); + })); +} + +TEST_F_S(ChangeStubs) { + EXPECT_FALSE(m_layer.setDisplayMode(1, {})); + EXPECT_FALSE(m_layer.setOriginPoint(1, {})); + EXPECT_FALSE(m_layer.setMirror(1, 2)); +} diff --git a/tests/unit/macos/test_mac_api_utils.cpp b/tests/unit/macos/test_mac_api_utils.cpp new file mode 100644 index 0000000..593ae6d --- /dev/null +++ b/tests/unit/macos/test_mac_api_utils.cpp @@ -0,0 +1,27 @@ +// local includes +#include "display_device/macos/mac_api_utils.h" +#include "fixtures/fixtures.h" + +// Specialized TEST macro(s) for this test file +#define TEST_S(...) DD_MAKE_TEST(TEST, MacApiUtils, __VA_ARGS__) + +TEST_S(IsSuccess) { + EXPECT_TRUE(display_device::mac_utils::isSuccess(0)); + EXPECT_FALSE(display_device::mac_utils::isSuccess(1)); + EXPECT_FALSE(display_device::mac_utils::isSuccess(-1)); +} + +TEST_S(FuzzyCompareRefreshRates) { + EXPECT_TRUE(display_device::mac_utils::fuzzyCompareRefreshRates({60, 1}, {5985, 100})); + EXPECT_TRUE(display_device::mac_utils::fuzzyCompareRefreshRates({60, 1}, {5920, 100})); + EXPECT_FALSE(display_device::mac_utils::fuzzyCompareRefreshRates({60, 1}, {5900, 100})); + EXPECT_FALSE(display_device::mac_utils::fuzzyCompareRefreshRates({60, 0}, {5985, 100})); + EXPECT_FALSE(display_device::mac_utils::fuzzyCompareRefreshRates({60, 1}, {5985, 0})); +} + +TEST_S(FuzzyCompareModes) { + EXPECT_TRUE(display_device::mac_utils::fuzzyCompareModes({{1920, 1080}, {60, 1}}, {{1920, 1080}, {5985, 100}})); + EXPECT_FALSE(display_device::mac_utils::fuzzyCompareModes({{1280, 1080}, {60, 1}}, {{1920, 1080}, {60, 1}})); + EXPECT_FALSE(display_device::mac_utils::fuzzyCompareModes({{1920, 720}, {60, 1}}, {{1920, 1080}, {60, 1}})); + EXPECT_FALSE(display_device::mac_utils::fuzzyCompareModes({{1920, 1080}, {60, 1}}, {{1920, 1080}, {50, 1}})); +} diff --git a/tests/unit/macos/test_mac_display_device.cpp b/tests/unit/macos/test_mac_display_device.cpp new file mode 100644 index 0000000..0d9e31a --- /dev/null +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -0,0 +1,441 @@ +// system includes +#include +#include +#include + +// local includes +#include "display_device/macos/mac_api_layer.h" +#include "display_device/macos/mac_api_utils.h" +#include "display_device/macos/mac_display_device.h" +#include "fixtures/fixtures.h" +#include "fixtures/test_utils.h" +#include "utils/mock_mac_api_layer.h" + +namespace { + // Convenience keywords for GMock + using ::testing::HasSubstr; + using ::testing::InSequence; + using ::testing::Return; + using ::testing::Sequence; + using ::testing::StrictMock; + + const display_device::MacDisplayMode CURRENT_MODE {{1920, 1080}, {60, 1}}; + const display_device::MacDisplayMode REQUESTED_MODE {{1280, 720}, {60, 1}}; + const display_device::MacDisplayModeList REQUESTED_MODES {REQUESTED_MODE}; + + // Test fixture(s) for this file + class MacDisplayDeviceMocked: public BaseTest { + public: + void expectActiveDeviceLookup(const Sequence &sequence, const std::string &device_id = "DeviceId1") const { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(display_device::MacDisplayIdList {1})); + EXPECT_CALL(*m_layer, getDeviceId(1)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(device_id)); + } + + void expectCurrentMode(const Sequence &sequence, const display_device::MacDisplayMode &mode) const { + EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(mode)); + } + + void expectAvailableModes(const Sequence &sequence, const display_device::MacDisplayModeList &modes) const { + EXPECT_CALL(*m_layer, getDisplayModes(1)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(modes)); + } + + void expectSetMode(const Sequence &sequence, const display_device::MacDisplayMode &mode, const bool result) const { + EXPECT_CALL(*m_layer, setDisplayMode(1, mode)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(result)); + } + + void expectModePreparation(const Sequence &sequence, const display_device::MacDisplayMode ¤t_mode = CURRENT_MODE, const display_device::MacDisplayModeList &available_modes = REQUESTED_MODES) const { + expectActiveDeviceLookup(sequence); + expectCurrentMode(sequence, current_mode); + expectAvailableModes(sequence, available_modes); + } + + std::shared_ptr> m_layer {std::make_shared>()}; + display_device::MacDisplayDevice m_mac_dd {m_layer}; + }; + + class MacDisplayDeviceSystem: public BaseTest { + public: + bool isSystemTest() const override { + return true; + } + + std::shared_ptr m_layer {std::make_shared()}; + display_device::MacDisplayDevice m_mac_dd {m_layer}; + }; + + /** + * @brief Guard for restoring macOS display modes in live tests. + */ + class MacModeGuard { + public: + /** + * @brief Constructor. + * @param mac_dd Display-device API to use for restoration. + * @param modes Display modes to restore. + */ + explicit MacModeGuard(display_device::MacDisplayDevice &mac_dd, display_device::MacDeviceDisplayModeMap modes): + m_mac_dd {mac_dd}, + m_modes {std::move(modes)} {} + + MacModeGuard(const MacModeGuard &) = delete; ///< Copy constructor. + MacModeGuard &operator=(const MacModeGuard &) = delete; ///< Copy assignment operator. + MacModeGuard(MacModeGuard &&) = delete; ///< Move constructor. + MacModeGuard &operator=(MacModeGuard &&) = delete; ///< Move assignment operator. + + /** + * @brief Destructor. + */ + ~MacModeGuard() { + static_cast(m_mac_dd.setDisplayModes(m_modes)); + } + + private: + display_device::MacDisplayDevice &m_mac_dd; ///< Display-device API. + display_device::MacDeviceDisplayModeMap m_modes; ///< Modes to restore. + }; + + // Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, MacDisplayDeviceMocked, __VA_ARGS__) +#define TEST_F_S_SYSTEM(...) DD_MAKE_TEST(TEST_F, MacDisplayDeviceSystem, __VA_ARGS__) +} // namespace + +TEST_F_S(NullptrLayerProvided) { + EXPECT_THAT([]() { + const auto mac_dd {display_device::MacDisplayDevice {nullptr}}; + }, + ThrowsMessage(HasSubstr("Nullptr provided for MacApiLayerInterface in MacDisplayDevice!"))); +} + +TEST_F_S(IsApiAccessAvailable) { + EXPECT_CALL(*m_layer, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_FALSE(m_mac_dd.isApiAccessAvailable()); +} + +TEST_F_S(EnumAvailableDevices) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Online)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {1, 2})); + + EXPECT_CALL(*m_layer, getDeviceId(1)) + .Times(1) + .WillOnce(Return("DeviceId1")); + EXPECT_CALL(*m_layer, getDeviceId(2)) + .Times(1) + .WillOnce(Return("DeviceId2")); + + EXPECT_CALL(*m_layer, getDisplayName(1)) + .Times(1) + .WillOnce(Return("1")); + EXPECT_CALL(*m_layer, getDisplayName(2)) + .Times(1) + .WillOnce(Return("2")); + + EXPECT_CALL(*m_layer, getFriendlyName(1)) + .Times(1) + .WillOnce(Return("FriendlyName1")); + EXPECT_CALL(*m_layer, getFriendlyName(2)) + .Times(1) + .WillOnce(Return("")); + + EXPECT_CALL(*m_layer, getEdid(1)) + .Times(1) + .WillOnce(Return(ut_consts::DEFAULT_EDID)); + EXPECT_CALL(*m_layer, getEdid(2)) + .Times(1) + .WillOnce(Return(std::vector {})); + + EXPECT_CALL(*m_layer, isActive(1)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_layer, isActive(2)) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + EXPECT_CALL(*m_layer, getDisplayScale(1)) + .Times(1) + .WillOnce(Return(display_device::Rational {200, 100})); + EXPECT_CALL(*m_layer, isMainDisplay(1)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_layer, getOriginPoint(1)) + .Times(1) + .WillOnce(Return(display_device::Point {0, 0})); + + const display_device::EnumeratedDeviceList expected_list { + {"DeviceId1", + "1", + "FriendlyName1", + ut_consts::DEFAULT_EDID_DATA, + display_device::EnumeratedDevice::Info { + {1920, 1080}, + display_device::Rational {200, 100}, + display_device::Rational {60, 1}, + true, + {0, 0}, + std::nullopt + }}, + {"DeviceId2", + "2", + "2", + std::nullopt, + std::nullopt} + }; + EXPECT_EQ(m_mac_dd.enumAvailableDevices(), expected_list); +} + +TEST_F_S(GetDisplayName) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Online)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {1, 2})); + EXPECT_CALL(*m_layer, getDeviceId(1)) + .Times(1) + .WillOnce(Return("DeviceId1")); + EXPECT_CALL(*m_layer, getDeviceId(2)) + .Times(1) + .WillOnce(Return("DeviceId2")); + EXPECT_CALL(*m_layer, getDisplayName(2)) + .Times(1) + .WillOnce(Return("2")); + + EXPECT_EQ(m_mac_dd.getDisplayName("DeviceId2"), "2"); +} + +TEST_F_S(GetDisplayNameUnknownDevice) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Online)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {1})); + EXPECT_CALL(*m_layer, getDeviceId(1)) + .Times(1) + .WillOnce(Return("DeviceId1")); + + EXPECT_EQ(m_mac_dd.getDisplayName("DeviceId2"), ""); +} + +TEST_F_S(TopologyRead) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {1, 2, 3})); + EXPECT_CALL(*m_layer, getDeviceId(1)) + .Times(1) + .WillOnce(Return("DeviceId1")); + EXPECT_CALL(*m_layer, getDeviceId(2)) + .Times(1) + .WillOnce(Return("DeviceId2")); + EXPECT_CALL(*m_layer, getDeviceId(3)) + .Times(1) + .WillOnce(Return("DeviceId3")); + EXPECT_CALL(*m_layer, getMirrorMaster(1)) + .Times(1) + .WillOnce(Return(0)); + EXPECT_CALL(*m_layer, getMirrorMaster(2)) + .Times(1) + .WillOnce(Return(1)); + EXPECT_CALL(*m_layer, getMirrorMaster(3)) + .Times(1) + .WillOnce(Return(0)); + + const display_device::MacActiveTopology expected_topology {{"DeviceId1", "DeviceId2"}, {"DeviceId3"}}; + EXPECT_EQ(m_mac_dd.getCurrentTopology(), expected_topology); +} + +TEST_F_S(TopologyWriteStub) { + EXPECT_FALSE(m_mac_dd.setTopology({{"DeviceId1"}})); +} + +TEST_F_S(IsTopologyValid) { + EXPECT_FALSE(m_mac_dd.isTopologyValid({/* no groups */})); + EXPECT_FALSE(m_mac_dd.isTopologyValid({{/* empty group */}})); + EXPECT_FALSE(m_mac_dd.isTopologyValid({{""}})); + EXPECT_TRUE(m_mac_dd.isTopologyValid({{"ID_1"}})); + EXPECT_TRUE(m_mac_dd.isTopologyValid({{"ID_1"}, {"ID_2"}})); + EXPECT_TRUE(m_mac_dd.isTopologyValid({{"ID_1", "ID_2"}})); + EXPECT_FALSE(m_mac_dd.isTopologyValid({{"ID_1", "ID_1"}})); + EXPECT_FALSE(m_mac_dd.isTopologyValid({{"ID_1"}, {"ID_1"}})); +} + +TEST_F_S(IsTopologyTheSame) { + EXPECT_TRUE(m_mac_dd.isTopologyTheSame({/* no groups */}, {/* no groups */})); + EXPECT_TRUE(m_mac_dd.isTopologyTheSame({{"ID_1"}}, {{"ID_1"}})); + EXPECT_FALSE(m_mac_dd.isTopologyTheSame({{"ID_1"}}, {{"ID_1"}, {"ID_2"}})); + EXPECT_TRUE(m_mac_dd.isTopologyTheSame({{"ID_1"}, {"ID_2"}}, {{"ID_2"}, {"ID_1"}})); + EXPECT_FALSE(m_mac_dd.isTopologyTheSame({{"ID_1"}, {"ID_2"}}, {{"ID_1", "ID_2"}})); + EXPECT_TRUE(m_mac_dd.isTopologyTheSame({{"ID_3"}, {"ID_1", "ID_2"}}, {{"ID_2", "ID_1"}, {"ID_3"}})); +} + +TEST_F_S(GetCurrentDisplayModes) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(2) + .WillRepeatedly(Return(display_device::MacDisplayIdList {1, 2})); + EXPECT_CALL(*m_layer, getDeviceId(1)) + .Times(2) + .WillRepeatedly(Return("DeviceId1")); + EXPECT_CALL(*m_layer, getDeviceId(2)) + .Times(1) + .WillOnce(Return("DeviceId2")); + EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + EXPECT_CALL(*m_layer, getCurrentDisplayMode(2)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{2560, 1440}, {120, 1}})); + + const display_device::MacDeviceDisplayModeMap expected_modes { + {"DeviceId1", {{1920, 1080}, {60, 1}}}, + {"DeviceId2", {{2560, 1440}, {120, 1}}} + }; + EXPECT_EQ(m_mac_dd.getCurrentDisplayModes({"DeviceId1", "DeviceId2"}), expected_modes); +} + +TEST_F_S(SetDisplayModes, EmptyModes) { + EXPECT_FALSE(m_mac_dd.setDisplayModes({})); +} + +TEST_F_S(SetDisplayModes, InactiveDisplay) { + Sequence sequence; + expectActiveDeviceLookup(sequence, "DeviceId2"); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1920, 1080}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, UnavailableMode) { + Sequence sequence; + expectModePreparation(sequence); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1024, 768}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, AlreadyCurrent) { + Sequence sequence; + expectActiveDeviceLookup(sequence); + expectCurrentMode(sequence, CURRENT_MODE); + + EXPECT_TRUE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1920, 1080}, {5985, 100}}}})); +} + +TEST_F_S(SetDisplayModes, Success) { + Sequence sequence; + expectModePreparation(sequence); + expectSetMode(sequence, REQUESTED_MODE, true); + expectCurrentMode(sequence, REQUESTED_MODE); + + EXPECT_TRUE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1280, 720}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, ApplyFailed) { + Sequence sequence; + expectModePreparation(sequence); + expectSetMode(sequence, REQUESTED_MODE, false); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1280, 720}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, VerificationFailedRollsBack) { + constexpr display_device::MacDisplayMode wrong_mode {{1024, 768}, {60, 1}}; + + Sequence sequence; + expectModePreparation(sequence, CURRENT_MODE, {REQUESTED_MODE, CURRENT_MODE}); + expectSetMode(sequence, REQUESTED_MODE, true); + expectCurrentMode(sequence, wrong_mode); + expectModePreparation(sequence, wrong_mode, {CURRENT_MODE}); + expectSetMode(sequence, CURRENT_MODE, true); + expectCurrentMode(sequence, CURRENT_MODE); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1280, 720}, {60, 1}}}})); +} + +TEST_F_S(IsPrimary) { + EXPECT_CALL(*m_layer, getDisplayIds(display_device::MacQueryType::Active)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayIdList {1})); + EXPECT_CALL(*m_layer, getDeviceId(1)) + .Times(1) + .WillOnce(Return("DeviceId1")); + EXPECT_CALL(*m_layer, isMainDisplay(1)) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_TRUE(m_mac_dd.isPrimary("DeviceId1")); +} + +TEST_F_S(SetAsPrimaryStub) { + EXPECT_FALSE(m_mac_dd.setAsPrimary("DeviceId1")); +} + +TEST_F_S(HdrStubs) { + const auto states {m_mac_dd.getCurrentHdrStates({"DeviceId1", "DeviceId2"})}; + + ASSERT_EQ(states.size(), 2U); + EXPECT_EQ(states.at("DeviceId1"), std::nullopt); + EXPECT_EQ(states.at("DeviceId2"), std::nullopt); + + EXPECT_TRUE(m_mac_dd.setHdrStates({})); + EXPECT_TRUE(m_mac_dd.setHdrStates({{"DeviceId1", std::nullopt}})); + EXPECT_FALSE(m_mac_dd.setHdrStates({{"DeviceId1", display_device::HdrState::Enabled}})); +} + +TEST_F_S_SYSTEM(SetCurrentDisplayMode) { + const auto active_displays {m_layer->getDisplayIds(display_device::MacQueryType::Active)}; + ASSERT_FALSE(active_displays.empty()); + + std::string device_id; + display_device::MacDisplayMode current_mode; + display_device::MacDisplayMode alternate_mode; + bool found_alternate {false}; + for (const auto display_id : active_displays) { + const auto maybe_current_mode {m_layer->getCurrentDisplayMode(display_id)}; + const auto modes {m_layer->getDisplayModes(display_id)}; + if (!maybe_current_mode || modes.empty()) { + continue; + } + + const auto alternate_it {std::ranges::find_if(modes, [&maybe_current_mode](const auto &mode) { + return !display_device::mac_utils::fuzzyCompareModes(mode, *maybe_current_mode); + })}; + if (alternate_it == std::end(modes)) { + continue; + } + + device_id = m_layer->getDeviceId(display_id); + current_mode = *maybe_current_mode; + alternate_mode = *alternate_it; + found_alternate = !device_id.empty(); + if (found_alternate) { + break; + } + } + + if (!found_alternate) { + GTEST_SKIP_("No active macOS display exposes an alternate desktop display mode."); + } + + const display_device::MacDeviceDisplayModeMap original_modes { + {device_id, current_mode} + }; + const MacModeGuard mode_guard {m_mac_dd, original_modes}; + + ASSERT_TRUE(m_mac_dd.setDisplayModes({{device_id, alternate_mode}})); + const auto changed_modes {m_mac_dd.getCurrentDisplayModes({device_id})}; + ASSERT_EQ(changed_modes.size(), 1U); + EXPECT_TRUE(display_device::mac_utils::fuzzyCompareModes(changed_modes.at(device_id), alternate_mode)); +} diff --git a/tests/unit/macos/test_persistent_state.cpp b/tests/unit/macos/test_persistent_state.cpp new file mode 100644 index 0000000..f5bd2f1 --- /dev/null +++ b/tests/unit/macos/test_persistent_state.cpp @@ -0,0 +1,107 @@ +// local includes +#include "display_device/macos/json.h" +#include "display_device/macos/persistent_state.h" +#include "display_device/noop_settings_persistence.h" +#include "fixtures/fixtures.h" +#include "fixtures/mock_settings_persistence.h" + +namespace { + // Convenience keywords for GMock + using ::testing::_; + using ::testing::Return; + using ::testing::StrictMock; + + std::optional> serializeState(const std::optional &state) { + if (state) { + bool is_ok {false}; + const auto data_string {display_device::toJson(*state, 2, &is_ok)}; + if (is_ok) { + return std::vector {std::begin(data_string), std::end(data_string)}; + } + } + + return std::nullopt; + } + + display_device::MacSingleDisplayConfigState makeState() { + return { + {{{"DeviceId1"}}, + {"DeviceId1"}}, + {display_device::MacSingleDisplayConfigState::Modified { + {{"DeviceId2"}}, + {{"DeviceId2", {{1920, 1080}, {60, 1}}}}, + {{"DeviceId2", {std::nullopt}}}, + {"DeviceId1"}, + }} + }; + } + + // Test fixture(s) for this file + class MacPersistentStateMocked: public BaseTest { + public: + std::shared_ptr> m_settings_persistence_api {std::make_shared>()}; + }; + + // Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, MacPersistentStateMocked, __VA_ARGS__) +} // namespace + +TEST_F_S(NoopPersistence) { + const display_device::MacPersistentState persistent_state {nullptr}; + EXPECT_FALSE(persistent_state.getState()); + EXPECT_TRUE(std::dynamic_pointer_cast(persistent_state.getSettingsPersistenceApi()) != nullptr); +} + +TEST_F_S(LoadEmptyState) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(std::optional> {std::vector {}})); + + const display_device::MacPersistentState persistent_state {m_settings_persistence_api}; + EXPECT_FALSE(persistent_state.getState()); +} + +TEST_F_S(LoadStoredState) { + const auto state {makeState()}; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(state))); + + const display_device::MacPersistentState persistent_state {m_settings_persistence_api}; + EXPECT_EQ(persistent_state.getState(), state); +} + +TEST_F_S(PersistState) { + const auto state {makeState()}; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(std::optional> {std::vector {}})); + + display_device::MacPersistentState persistent_state {m_settings_persistence_api}; + + EXPECT_CALL(*m_settings_persistence_api, store(_)) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_TRUE(persistent_state.persistState(state)); + EXPECT_EQ(persistent_state.getState(), state); +} + +TEST_F_S(ClearState) { + const auto state {makeState()}; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(state))); + + display_device::MacPersistentState persistent_state {m_settings_persistence_api}; + + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_TRUE(persistent_state.persistState(std::nullopt)); + EXPECT_FALSE(persistent_state.getState()); +} diff --git a/tests/unit/macos/test_settings_manager.cpp b/tests/unit/macos/test_settings_manager.cpp new file mode 100644 index 0000000..b421969 --- /dev/null +++ b/tests/unit/macos/test_settings_manager.cpp @@ -0,0 +1,453 @@ +// system includes +#include + +// local includes +#include "display_device/macos/json.h" +#include "display_device/macos/settings_manager.h" +#include "display_device/noop_audio_context.h" +#include "fixtures/fixtures.h" +#include "fixtures/mock_audio_context.h" +#include "fixtures/mock_settings_persistence.h" +#include "utils/mock_mac_display_device.h" + +namespace { + // Convenience keywords for GMock + using ::testing::HasSubstr; + using ::testing::InSequence; + using ::testing::Return; + using ::testing::Sequence; + using ::testing::StrictMock; + + const display_device::MacActiveTopology DEFAULT_TOPOLOGY { + {"DeviceId1"}, + {"DeviceId2"} + }; + const display_device::EnumeratedDeviceList DEFAULT_DEVICES { + {.m_device_id = "DeviceId1", .m_info = display_device::EnumeratedDevice::Info {.m_primary = true}}, + {.m_device_id = "DeviceId2", .m_info = display_device::EnumeratedDevice::Info {.m_primary = false}}, + {.m_device_id = "DeviceId3"}, + }; + const display_device::MacDeviceDisplayModeMap DEFAULT_MODES { + {"DeviceId1", {{1920, 1080}, {60, 1}}}, + {"DeviceId2", {{2560, 1440}, {120, 1}}}, + }; + const display_device::MacDeviceDisplayModeMap CHANGED_MODES { + {"DeviceId1", {{1280, 720}, {60, 1}}}, + {"DeviceId2", {{2560, 1440}, {120, 1}}}, + }; + const display_device::MacDeviceDisplayModeMap CURRENT_REVERT_MODES { + {"DeviceId1", {{1280, 720}, {60, 1}}}, + {"DeviceId2", {{2560, 1440}, {120, 1}}}, + }; + + std::optional> serializeState(const std::optional &state) { + if (state) { + bool is_ok {false}; + const auto data_string {display_device::toJson(*state, 2, &is_ok)}; + if (is_ok) { + return std::vector {std::begin(data_string), std::end(data_string)}; + } + } + + return std::nullopt; + } + + std::optional> serializeNoState() { + return std::vector {}; + } + + display_device::MacSingleDisplayConfigState makeState() { + return { + {{{"DeviceId1"}}, + {"DeviceId1"}}, + {display_device::MacSingleDisplayConfigState::Modified { + {{"DeviceId2"}}, + {{"DeviceId2", {{1920, 1080}, {60, 1}}}}, + {{"DeviceId2", {std::nullopt}}}, + {"DeviceId1"}, + }} + }; + } + + display_device::MacSingleDisplayConfigState makeModeState() { + return { + {DEFAULT_TOPOLOGY, + {"DeviceId1"}}, + {display_device::MacSingleDisplayConfigState::Modified { + DEFAULT_TOPOLOGY, + DEFAULT_MODES, + {}, + {}, + }} + }; + } + + display_device::MacSingleDisplayConfigState makeAppliedModeState() { + return makeModeState(); + } + + // Test fixture(s) for this file + class MacSettingsManagerMocked: public BaseTest { + public: + display_device::MacSettingsManager &getImpl() { + if (!m_impl) { + m_impl = std::make_unique( + m_dd_api, + m_audio_context_api, + std::make_unique(m_settings_persistence_api), + display_device::MacWorkarounds {} + ); + } + + return *m_impl; + } + + void expectNoStateLoad() const { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + } + + void expectStoredStateLoad(const display_device::MacSingleDisplayConfigState &state) const { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(state))); + } + + void expectApiAvailable(const Sequence &sequence) const { + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(true)); + } + + void expectApplyPreparation(const Sequence &sequence) const { + expectApiAvailable(sequence); + EXPECT_CALL(*m_dd_api, getCurrentTopology()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(DEFAULT_TOPOLOGY)); + EXPECT_CALL(*m_dd_api, isTopologyValid(DEFAULT_TOPOLOGY)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, enumAvailableDevices()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(DEFAULT_DEVICES)); + } + + void expectCurrentModes(const Sequence &sequence, const display_device::MacDeviceDisplayModeMap &modes) const { + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(modes)); + } + + void expectSetModes(const Sequence &sequence, const display_device::MacDeviceDisplayModeMap &modes, const bool result) const { + EXPECT_CALL(*m_dd_api, setDisplayModes(modes)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(result)); + } + + void expectStoreState(const Sequence &sequence, const display_device::MacSingleDisplayConfigState &state, const bool result) const { + EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(state))) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(result)); + } + + void expectModeRevertPreparation(const Sequence &sequence, const display_device::MacDeviceDisplayModeMap ¤t_modes) const { + expectApiAvailable(sequence); + EXPECT_CALL(*m_dd_api, getCurrentTopology()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(DEFAULT_TOPOLOGY)); + EXPECT_CALL(*m_dd_api, isTopologyValid(DEFAULT_TOPOLOGY)) + .Times(2) + .InSequence(sequence) + .WillRepeatedly(Return(true)); + EXPECT_CALL(*m_dd_api, isTopologyTheSame(DEFAULT_TOPOLOGY, DEFAULT_TOPOLOGY)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(true)); + expectCurrentModes(sequence, current_modes); + expectSetModes(sequence, DEFAULT_MODES, true); + } + + std::shared_ptr> m_dd_api {std::make_shared>()}; + std::shared_ptr> m_settings_persistence_api {std::make_shared>()}; + std::shared_ptr> m_audio_context_api {std::make_shared>()}; + std::unique_ptr m_impl; + }; + + // Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, MacSettingsManagerMocked, __VA_ARGS__) +} // namespace + +TEST_F_S(NullptrDisplayDeviceApiProvided) { + EXPECT_THAT([]() { + const display_device::MacSettingsManager settings_manager(nullptr, nullptr, nullptr, {}); + }, + ThrowsMessage(HasSubstr("Nullptr provided for MacDisplayDeviceInterface in MacSettingsManager!"))); +} + +TEST_F_S(NoopAudioContext) { + const display_device::MacSettingsManager settings_manager {m_dd_api, nullptr, std::make_unique(nullptr), {}}; + EXPECT_TRUE(std::dynamic_pointer_cast(settings_manager.getAudioContextApi()) != nullptr); +} + +TEST_F_S(NullptrPersistentStateProvided) { + EXPECT_THAT([this]() { + const display_device::MacSettingsManager settings_manager(m_dd_api, nullptr, nullptr, {}); + }, + ThrowsMessage(HasSubstr("Nullptr provided for MacPersistentState in MacSettingsManager!"))); +} + +TEST_F_S(EnumAvailableDevices) { + const display_device::EnumeratedDeviceList test_list { + {"DeviceId1", + "", + "FriendlyName1", + std::nullopt} + }; + + expectNoStateLoad(); + EXPECT_CALL(*m_dd_api, enumAvailableDevices()) + .Times(1) + .WillOnce(Return(test_list)); + + EXPECT_EQ(getImpl().enumAvailableDevices(), test_list); +} + +TEST_F_S(GetDisplayName) { + expectNoStateLoad(); + EXPECT_CALL(*m_dd_api, getDisplayName("DeviceId1")) + .Times(1) + .WillOnce(Return("DisplayName1")); + + EXPECT_EQ(getImpl().getDisplayName("DeviceId1"), "DisplayName1"); +} + +TEST_F_S(ResetPersistence, NoPersistence) { + expectNoStateLoad(); + + EXPECT_TRUE(getImpl().resetPersistence()); +} + +TEST_F_S(ResetPersistence, FailedToReset) { + expectStoredStateLoad(makeState()); + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_FALSE(getImpl().resetPersistence()); +} + +TEST_F_S(ResetPersistence, PersistenceReset, NoCapturedDevice) { + expectStoredStateLoad(makeState()); + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_audio_context_api, isCaptured()) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_TRUE(getImpl().resetPersistence()); +} + +TEST_F_S(ResetPersistence, PersistenceReset, WithCapturedDevice) { + expectStoredStateLoad(makeState()); + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_audio_context_api, isCaptured()) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_audio_context_api, release()) + .Times(1); + + EXPECT_TRUE(getImpl().resetPersistence()); +} + +TEST_F_S(ApplySettings, ApiTemporarilyUnavailable) { + expectNoStateLoad(); + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_EQ(getImpl().applySettings({}), display_device::MacSettingsManager::ApplyResult::ApiTemporarilyUnavailable); +} + +TEST_F_S(ApplySettings, HdrUnsupported) { + expectNoStateLoad(); + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ( + getImpl().applySettings({.m_hdr_state = display_device::HdrState::Enabled}), + display_device::MacSettingsManager::ApplyResult::HdrStatePrepFailed + ); +} + +TEST_F_S(ApplySettings, DisplayModeSuccess) { + const auto expected_state {makeAppliedModeState()}; + + expectNoStateLoad(); + Sequence sequence; + expectApplyPreparation(sequence); + expectCurrentModes(sequence, DEFAULT_MODES); + expectSetModes(sequence, CHANGED_MODES, true); + expectCurrentModes(sequence, CHANGED_MODES); + expectStoreState(sequence, expected_state, true); + + EXPECT_EQ( + getImpl().applySettings({.m_resolution = display_device::Resolution {1280, 720}}), + display_device::MacSettingsManager::ApplyResult::Ok + ); +} + +TEST_F_S(ApplySettings, DevicePrepUnsupported) { + expectNoStateLoad(); + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ( + getImpl().applySettings({.m_device_prep = display_device::SingleDisplayConfiguration::DevicePreparation::EnsureActive}), + display_device::MacSettingsManager::ApplyResult::DevicePrepFailed + ); +} + +TEST_F_S(ApplySettings, VerifyOnlyNoChanges) { + const display_device::MacSingleDisplayConfigState expected_state { + {DEFAULT_TOPOLOGY, + {"DeviceId1"}}, + {display_device::MacSingleDisplayConfigState::Modified { + DEFAULT_TOPOLOGY, + {}, + {}, + {}, + }} + }; + + expectNoStateLoad(); + Sequence sequence; + expectApplyPreparation(sequence); + expectStoreState(sequence, expected_state, true); + + EXPECT_EQ(getImpl().applySettings({}), display_device::MacSettingsManager::ApplyResult::Ok); +} + +TEST_F_S(ApplySettings, InactiveDevice) { + expectNoStateLoad(); + Sequence sequence; + expectApplyPreparation(sequence); + + EXPECT_EQ( + getImpl().applySettings({.m_device_id = "DeviceId3"}), + display_device::MacSettingsManager::ApplyResult::DevicePrepFailed + ); +} + +TEST_F_S(ApplySettings, DisplayModeApplyFailed) { + expectNoStateLoad(); + Sequence sequence; + expectApplyPreparation(sequence); + expectCurrentModes(sequence, DEFAULT_MODES); + expectSetModes(sequence, CHANGED_MODES, false); + + EXPECT_EQ( + getImpl().applySettings({.m_resolution = display_device::Resolution {1280, 720}}), + display_device::MacSettingsManager::ApplyResult::DisplayModePrepFailed + ); +} + +TEST_F_S(ApplySettings, PersistenceFailureRollsBackModes) { + const auto expected_state {makeAppliedModeState()}; + + expectNoStateLoad(); + Sequence sequence; + expectApplyPreparation(sequence); + expectCurrentModes(sequence, DEFAULT_MODES); + expectSetModes(sequence, CHANGED_MODES, true); + expectCurrentModes(sequence, CHANGED_MODES); + expectStoreState(sequence, expected_state, false); + expectSetModes(sequence, DEFAULT_MODES, true); + + EXPECT_EQ( + getImpl().applySettings({.m_resolution = display_device::Resolution {1280, 720}}), + display_device::MacSettingsManager::ApplyResult::PersistenceSaveFailed + ); +} + +TEST_F_S(RevertSettings, NoPersistence) { + expectNoStateLoad(); + + EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::Ok); +} + +TEST_F_S(RevertSettings, ApiTemporarilyUnavailable) { + expectStoredStateLoad(makeState()); + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::ApiTemporarilyUnavailable); +} + +TEST_F_S(RevertSettings, ModeOnly) { + expectStoredStateLoad(makeModeState()); + Sequence sequence; + expectModeRevertPreparation(sequence, CURRENT_REVERT_MODES); + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(true)); + EXPECT_CALL(*m_audio_context_api, isCaptured()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(false)); + + EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::Ok); +} + +TEST_F_S(RevertSettings, PersistenceFailureRollsBackModes) { + expectStoredStateLoad(makeModeState()); + Sequence sequence; + expectModeRevertPreparation(sequence, CURRENT_REVERT_MODES); + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(false)); + expectSetModes(sequence, CURRENT_REVERT_MODES, true); + + EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::PersistenceSaveFailed); +} + +TEST_F_S(RevertSettings, TopologyUnsupported) { + auto state {makeModeState()}; + state.m_modified.m_topology = {{"DeviceId2"}}; + + expectStoredStateLoad(state); + InSequence sequence; + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, getCurrentTopology()) + .Times(1) + .WillOnce(Return(DEFAULT_TOPOLOGY)); + EXPECT_CALL(*m_dd_api, isTopologyValid(DEFAULT_TOPOLOGY)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, isTopologyValid(state.m_modified.m_topology)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, isTopologyTheSame(DEFAULT_TOPOLOGY, state.m_modified.m_topology)) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::SwitchingTopologyFailed); +} diff --git a/tests/unit/macos/test_settings_utils.cpp b/tests/unit/macos/test_settings_utils.cpp new file mode 100644 index 0000000..2cc5e78 --- /dev/null +++ b/tests/unit/macos/test_settings_utils.cpp @@ -0,0 +1,119 @@ +// local includes +#include "display_device/macos/settings_utils.h" +#include "fixtures/fixtures.h" +#include "utils/mock_mac_display_device.h" + +namespace { + using ::testing::Return; + using ::testing::StrictMock; +} // namespace + +// Specialized TEST macro(s) for this test file +#define TEST_S(...) DD_MAKE_TEST(TEST, MacSettingsUtils, __VA_ARGS__) + +TEST_S(FlattenTopology) { + EXPECT_EQ(display_device::mac_utils::flattenTopology({}), display_device::StringSet {}); + EXPECT_EQ( + display_device::mac_utils::flattenTopology({{"DeviceId1"}, {"DeviceId2", "DeviceId3"}, {"DeviceId1"}}), + (display_device::StringSet {"DeviceId1", "DeviceId2", "DeviceId3"}) + ); +} + +TEST_S(GetPrimaryDevice) { + StrictMock mac_dd; + + EXPECT_CALL(mac_dd, isPrimary("DeviceId1")) + .Times(1) + .WillOnce(Return(false)); + EXPECT_CALL(mac_dd, isPrimary("DeviceId2")) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ(display_device::mac_utils::getPrimaryDevice(mac_dd, {{"DeviceId1"}, {"DeviceId2"}}), "DeviceId2"); +} + +TEST_S(ComputeInitialState) { + const display_device::MacSingleDisplayConfigState::Initial previous_state { + {{"DeviceId3"}}, + {"DeviceId3"} + }; + const display_device::EnumeratedDeviceList devices { + {.m_device_id = "DeviceId1", .m_info = display_device::EnumeratedDevice::Info {.m_primary = true}}, + {.m_device_id = "DeviceId2", .m_info = display_device::EnumeratedDevice::Info {.m_primary = false}}, + }; + + EXPECT_EQ(display_device::mac_utils::computeInitialState(previous_state, {{"DeviceId1"}}, devices), previous_state); + EXPECT_EQ( + display_device::mac_utils::computeInitialState(std::nullopt, {{"DeviceId1"}, {"DeviceId2"}}, devices), + (display_device::MacSingleDisplayConfigState::Initial {{{"DeviceId1"}, {"DeviceId2"}}, {"DeviceId1"}}) + ); +} + +TEST_S(ComputeInitialState, NoPrimaryDevice) { + const display_device::EnumeratedDeviceList devices { + {.m_device_id = "DeviceId1", .m_info = display_device::EnumeratedDevice::Info {.m_primary = false}}, + {.m_device_id = "DeviceId2"} + }; + + EXPECT_FALSE(display_device::mac_utils::computeInitialState(std::nullopt, {{"DeviceId1"}}, devices)); +} + +TEST_S(StripInitialState) { + const display_device::MacSingleDisplayConfigState::Initial initial_state { + {{"DeviceId1", "DeviceId2"}, {"DeviceId3"}}, + {"DeviceId2", "DeviceId4"} + }; + const display_device::EnumeratedDeviceList devices { + {.m_device_id = "DeviceId1", .m_info = display_device::EnumeratedDevice::Info {.m_primary = false}}, + {.m_device_id = "DeviceId3", .m_info = display_device::EnumeratedDevice::Info {.m_primary = true}}, + }; + + EXPECT_EQ( + display_device::mac_utils::stripInitialState(initial_state, devices), + (display_device::MacSingleDisplayConfigState::Initial {{{"DeviceId1"}, {"DeviceId3"}}, {"DeviceId3"}}) + ); +} + +TEST_S(ComputeNewDisplayModes) { + const display_device::MacDeviceDisplayModeMap original_modes { + {"DeviceId1", {{1920, 1080}, {60, 1}}}, + {"DeviceId2", {{1920, 1080}, {120, 1}}}, + {"DeviceId3", {{2560, 1440}, {60, 1}}}, + }; + + EXPECT_EQ( + display_device::mac_utils::computeNewDisplayModes( + display_device::Resolution {1280, 720}, + display_device::FloatingPoint {display_device::Rational {144, 1}}, + false, + "DeviceId1", + {"DeviceId2"}, + original_modes + ), + (display_device::MacDeviceDisplayModeMap { + {"DeviceId1", {{1280, 720}, {144, 1}}}, + {"DeviceId2", {{1280, 720}, {120, 1}}}, + {"DeviceId3", {{2560, 1440}, {60, 1}}}, + }) + ); + + EXPECT_EQ( + display_device::mac_utils::computeNewDisplayModes( + std::nullopt, + display_device::FloatingPoint {119.88}, + true, + "DeviceId1", + {"DeviceId2"}, + original_modes + ), + (display_device::MacDeviceDisplayModeMap { + {"DeviceId1", {{1920, 1080}, {1198800, 10000}}}, + {"DeviceId2", {{1920, 1080}, {1198800, 10000}}}, + {"DeviceId3", {{2560, 1440}, {60, 1}}}, + }) + ); +} + +TEST_S(NoopGuard) { + EXPECT_NO_THROW(display_device::mac_utils::noopGuard()); +} diff --git a/tests/unit/macos/utils/mock_mac_api_layer.h b/tests/unit/macos/utils/mock_mac_api_layer.h new file mode 100644 index 0000000..37f86d8 --- /dev/null +++ b/tests/unit/macos/utils/mock_mac_api_layer.h @@ -0,0 +1,34 @@ +#pragma once + +// system includes +#include + +// local includes +#include "display_device/macos/mac_api_layer_interface.h" + +namespace display_device { + class MockMacApiLayer: public MacApiLayerInterface { // NOSONAR(cpp:S1448,cpp:S1820): GMock class intentionally mirrors the full platform API interface. + public: + MOCK_METHOD(bool, isApiAccessAvailable, (), (const, override)); + MOCK_METHOD(std::string, getErrorString, (MacApiError), (const, override)); + MOCK_METHOD(MacDisplayIdList, getDisplayIds, (MacQueryType), (const, override)); + MOCK_METHOD(std::optional, declareUserActivity, (const std::string &), (override)); + MOCK_METHOD(std::optional, createDisplaySleepAssertion, (const std::string &), (override)); + MOCK_METHOD(bool, releasePowerAssertion, (MacPowerAssertionId), (override)); + MOCK_METHOD(std::string, getDeviceId, (MacDisplayId), (const, override)); + MOCK_METHOD(std::optional, getCurrentDisplayMode, (MacDisplayId), (const, override)); + MOCK_METHOD(MacDisplayModeList, getDisplayModes, (MacDisplayId), (const, override)); + MOCK_METHOD(std::string, getDisplayName, (MacDisplayId), (const, override)); + MOCK_METHOD(std::string, getFriendlyName, (MacDisplayId), (const, override)); + MOCK_METHOD(std::vector, getEdid, (MacDisplayId), (const, override)); + MOCK_METHOD(std::optional, getDisplayScale, (MacDisplayId), (const, override)); + MOCK_METHOD(std::optional, getOriginPoint, (MacDisplayId), (const, override)); + MOCK_METHOD(bool, isMainDisplay, (MacDisplayId), (const, override)); + MOCK_METHOD(bool, isActive, (MacDisplayId), (const, override)); + MOCK_METHOD(bool, isOnline, (MacDisplayId), (const, override)); + MOCK_METHOD(MacDisplayId, getMirrorMaster, (MacDisplayId), (const, override)); + MOCK_METHOD(bool, setDisplayMode, (MacDisplayId, const MacDisplayMode &), (override)); + MOCK_METHOD(bool, setOriginPoint, (MacDisplayId, const Point &), (override)); + MOCK_METHOD(bool, setMirror, (MacDisplayId, MacDisplayId), (override)); + }; +} // namespace display_device diff --git a/tests/unit/macos/utils/mock_mac_display_device.h b/tests/unit/macos/utils/mock_mac_display_device.h new file mode 100644 index 0000000..12fff19 --- /dev/null +++ b/tests/unit/macos/utils/mock_mac_display_device.h @@ -0,0 +1,26 @@ +#pragma once + +// system includes +#include + +// local includes +#include "display_device/macos/mac_display_device_interface.h" + +namespace display_device { + class MockMacDisplayDevice: public MacDisplayDeviceInterface { // NOSONAR(cpp:S1448): GMock class intentionally mirrors the full display-device interface. + public: + MOCK_METHOD(bool, isApiAccessAvailable, (), (const, override)); + MOCK_METHOD(EnumeratedDeviceList, enumAvailableDevices, (), (const, override)); + MOCK_METHOD(std::string, getDisplayName, (const std::string &), (const, override)); + MOCK_METHOD(MacActiveTopology, getCurrentTopology, (), (const, override)); + MOCK_METHOD(bool, isTopologyValid, (const MacActiveTopology &), (const, override)); + MOCK_METHOD(bool, isTopologyTheSame, (const MacActiveTopology &, const MacActiveTopology &), (const, override)); + MOCK_METHOD(bool, setTopology, (const MacActiveTopology &), (override)); + MOCK_METHOD(MacDeviceDisplayModeMap, getCurrentDisplayModes, (const StringSet &), (const, override)); + MOCK_METHOD(bool, setDisplayModes, (const MacDeviceDisplayModeMap &), (override)); + MOCK_METHOD(bool, isPrimary, (const std::string &), (const, override)); + MOCK_METHOD(bool, setAsPrimary, (const std::string &), (override)); + MOCK_METHOD(MacHdrStateMap, getCurrentHdrStates, (const StringSet &), (const, override)); + MOCK_METHOD(bool, setHdrStates, (const MacHdrStateMap &), (override)); + }; +} // namespace display_device diff --git a/tests/unit/windows/test_display_power.cpp b/tests/unit/windows/test_display_power.cpp new file mode 100644 index 0000000..d6214a8 --- /dev/null +++ b/tests/unit/windows/test_display_power.cpp @@ -0,0 +1,75 @@ +// system includes +#include +#include +#include + +// local includes +#include "display_device/windows/display_power.h" +#include "fixtures/fixtures.h" +#include "utils/mock_win_api_layer.h" + +namespace { + using namespace std::chrono_literals; + + // Convenience keywords for GMock + using ::testing::HasSubstr; + using ::testing::InSequence; + using ::testing::Return; + using ::testing::StrictMock; + + // Test fixture(s) for this file + class WinDisplayPowerTest: public BaseTest { + public: + std::shared_ptr> m_layer {std::make_shared>()}; + display_device::WinDisplayPower m_power {m_layer}; + }; + + // Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, WinDisplayPowerTest, __VA_ARGS__) +} // namespace + +TEST_F_S(NullptrLayerProvided) { + EXPECT_THAT([]() { + const auto power {display_device::WinDisplayPower {nullptr}}; + }, + ThrowsMessage(HasSubstr("Nullptr provided for WinApiLayerInterface in WinDisplayPower!"))); +} + +TEST_F_S(WakeDisplayForwardsTimeout) { + EXPECT_CALL(*m_layer, wakeDisplay(250ms)) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_TRUE(m_power.wakeDisplay("\\\\.\\DISPLAY1", 250ms)); +} + +TEST_F_S(WakeDisplayReturnsFalseWhenApiFails) { + EXPECT_CALL(*m_layer, wakeDisplay(250ms)) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_FALSE(m_power.wakeDisplay("\\\\.\\DISPLAY1", 250ms)); +} + +TEST_F_S(KeepDisplayAwakeCreatesAndRestoresRequest) { + InSequence sequence; + EXPECT_CALL(*m_layer, keepDisplayAwake()) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_layer, restorePowerRequest()) + .Times(1) + .WillOnce(Return(true)); + + { + const auto guard {m_power.keepDisplayAwake("Test capture")}; + ASSERT_NE(guard, nullptr); + } +} + +TEST_F_S(KeepDisplayAwakeReturnsNullptrWhenApiFails) { + EXPECT_CALL(*m_layer, keepDisplayAwake()) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_EQ(m_power.keepDisplayAwake("Test capture"), nullptr); +} diff --git a/tests/unit/windows/utils/mock_win_api_layer.h b/tests/unit/windows/utils/mock_win_api_layer.h index 871e742..3bfb6e1 100644 --- a/tests/unit/windows/utils/mock_win_api_layer.h +++ b/tests/unit/windows/utils/mock_win_api_layer.h @@ -7,10 +7,13 @@ #include "display_device/windows/win_api_layer_interface.h" namespace display_device { - class MockWinApiLayer: public WinApiLayerInterface { + class MockWinApiLayer: public WinApiLayerInterface { // NOSONAR(cpp:S1448): GMock class intentionally mirrors the full platform API interface. public: MOCK_METHOD(std::string, getErrorString, (LONG), (const, override)); MOCK_METHOD(std::optional, queryDisplayConfig, (QueryType), (const, override)); + MOCK_METHOD(bool, wakeDisplay, (std::chrono::milliseconds), (override)); + MOCK_METHOD(bool, keepDisplayAwake, (), (override)); + MOCK_METHOD(bool, restorePowerRequest, (), (override)); MOCK_METHOD(std::string, getDeviceId, (const DISPLAYCONFIG_PATH_INFO &), (const, override)); MOCK_METHOD(std::vector, getEdid, (const DISPLAYCONFIG_PATH_INFO &), (const, override)); MOCK_METHOD(std::string, getMonitorDevicePath, (const DISPLAYCONFIG_PATH_INFO &), (const, override));