From 80c7be4ca8fcc2d0947a7505f19312e1dc198173 Mon Sep 17 00:00:00 2001 From: marton Date: Thu, 18 Jun 2026 18:45:17 -0400 Subject: [PATCH 01/17] plan & spec for macos implementation --- MACOS-PLAN.md | 699 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 MACOS-PLAN.md diff --git a/MACOS-PLAN.md b/MACOS-PLAN.md new file mode 100644 index 0000000..3edf963 --- /dev/null +++ b/MACOS-PLAN.md @@ -0,0 +1,699 @@ +# macOS Implementation Plan + +## Goals + +Add macOS support to `libdisplaydevice` using public macOS APIs only. The first usable macOS release should support safe read-only enumeration and active-display resolution/refresh changes with reliable revert behavior. More complex display arrangement behavior can follow once the basic platform backend is proven. + +The implementation should preserve the existing public library contract where macOS can honestly support it, and fail explicitly where public macOS APIs do not provide equivalent behavior. + +## Non-goals + +- Do not use private or reverse-engineered macOS APIs. +- Do not promise Windows feature parity where macOS cannot provide it. +- Do not make permanent display configuration changes in the initial implementation. +- Do not implement display audio-device restoration in macOS v1. +- Do not implement per-display HDR enable/disable unless a public display-level API is identified. + +## Current Project Shape + +The repository already has a useful platform split: + +- `src/common` contains shared public types, persistence interfaces, logging, JSON helpers, and other platform-neutral pieces. +- `src/windows` contains the Windows platform implementation. +- `src/CMakeLists.txt` already selects `windows` on `WIN32`, and currently installs a dummy interface target for `APPLE`. +- The public top-level library target is `libdisplaydevice::display_device`, which links `libdisplaydevice::common` plus `libdisplaydevice::platform`. + +The Windows implementation uses three layers that should be mirrored on macOS: + +- A low-level, mockable OS API wrapper. +- A higher-level display-device service. +- A `SettingsManager` that coordinates apply/revert transactions and persistence. + +Keeping the same shape should make the macOS backend testable without requiring real monitor rearrangement during normal unit tests. + +## Public API Support Matrix + +### Easy To Support + +#### `SettingsManagerInterface::enumAvailableDevices` + +macOS can provide useful display enumeration via CoreGraphics and IOKit. + +Expected fields: + +- `m_device_id`: generated by this library from best-available stable metadata. +- `m_display_name`: a logical display name, likely derived from the active `CGDirectDisplayID`. +- `m_friendly_name`: product name from IOKit display dictionaries when available. +- `m_edid`: parsed from `IODisplayEDID` when available. +- `m_info`: populated for active/drawable displays. +- `m_info.m_resolution`: pixel resolution. +- `m_info.m_resolution_scale`: derived from pixel dimensions and point bounds. +- `m_info.m_refresh_rate`: current display mode refresh rate. +- `m_info.m_primary`: true when `CGDisplayIsMain(display)` is true. +- `m_info.m_origin_point`: display bounds origin. +- `m_info.m_hdr_state`: `std::nullopt` for v1. + +#### `SettingsManagerInterface::getDisplayName` + +Supported for active displays. The display name should be stable enough for logging and display lookup within the current system state, but the durable user-facing identity should remain `m_device_id`. + +#### `SettingsManagerInterface::resetPersistence` + +Straightforward once macOS persistence state exists. This should behave like Windows: if no state is cached, return success; otherwise clear persisted state and release any captured context. macOS v1 will use `NoopAudioContext`. + +#### `applySettings` With `DevicePreparation::VerifyOnly` + +Supported when the target device is already active. This is the safest first mutating path because it does not require topology changes. + +#### `applySettings` With Resolution And Refresh Rate For Active Displays + +Supported through CoreGraphics display modes. The implementation should only select modes that are usable for the desktop GUI and should verify the current display mode after applying changes. + +#### `revertSettings` For Supported Mutations + +Supported for any state that macOS v1 knows how to restore. In early phases, that means mode-only changes. Later, it can include main-display and topology/mirroring changes. + +### Hard But Feasible + +#### Stable Device IDs + +`CGDirectDisplayID` is convenient but not enough by itself. Generate IDs by hashing the most stable available data: + +1. EDID bytes when available. +2. IOKit vendor, product, serial, serial string, and display location when available. +3. CoreGraphics vendor, model, serial, unit number, and display id as fallback. + +The fallback should be documented as less stable. This mirrors the Windows implementation's philosophy: produce a persistent ID when possible and a best-effort ID when the OS/device does not expose enough information. + +#### Resolution Scale + +macOS has point dimensions and pixel dimensions, especially with HiDPI modes. The common `Resolution` type should remain pixel-based. Scale can be computed from current pixel dimensions divided by CoreGraphics point bounds. + +Open decision: + +- Whether scale should be represented as one axis, both axes, or width-only. The existing type has one `FloatingPoint`, so width-based scale is probably the least surprising match for Windows. + +#### Refresh Rate Matching + +Some modes may report `0`, rounded values, or variable refresh behavior. Mode selection should use fuzzy comparison and should prefer exact resolution first, then nearest refresh rate. + +Open decision: + +- If the requested refresh rate is unavailable but a mode with matching resolution exists, should macOS fail or choose the closest refresh? Windows currently uses a relaxed pass and then a stricter pass. A similar two-step strategy is reasonable. + +#### `DevicePreparation::EnsureActive` + +This is feasible only for displays that are online and can be made drawable through public display configuration APIs. It is not feasible for disconnected remembered displays. + +For v1: + +- Succeed if the display is already active. +- Succeed if the display is online and CoreGraphics can make it active by unmirroring or reconfiguring it. +- Fail cleanly if the display is not online. + +#### `DevicePreparation::EnsurePrimary` + +Likely feasible by moving the target display to origin `(0, 0)` and verifying `CGDisplayIsMain(display)`. This needs real-device testing because macOS main-display behavior is tied to display arrangement and menu bar ownership. + +For v1: + +- Support active displays first. +- Verify after applying. +- Roll back if verification fails. + +#### Mirroring And Topology Groups + +CoreGraphics supports mirroring through `CGConfigureDisplayMirrorOfDisplay`. This can map to `ActiveTopology` groups: + +- `{{A}, {B}}`: extended displays. +- `{{A, B}}`: mirrored displays. +- `{{A, B}, {C}}`: mixed mirrored plus extended topology. + +This is hard because CoreGraphics distinguishes active and online displays, and hardware/software mirroring can affect what is drawable. It should be implemented after mode-only changes are stable. + +### Not Feasible With Public APIs + +#### HDR Enable/Disable + +Do not implement HDR write support in macOS v1. Public APIs expose HDR/EDR concepts for rendering and playback, but no public per-display equivalent to Windows advanced color state was identified. + +Expected behavior: + +- Enumeration sets `m_hdr_state` to `std::nullopt`. +- `getCurrentHdrStates` returns each requested device with `std::nullopt`. +- `setHdrStates` returns true only if all requested states are `std::nullopt` or the map has no actual requested changes. +- `applySettings` with `m_hdr_state` should return `HdrStatePrepFailed` before making unrelated changes, unless the implementation can prove no device supports a meaningful HDR mutation. + +Open decision: + +- Whether `applySettings` should fail immediately when `m_hdr_state` is supplied, or ignore it when all target devices report `std::nullopt`. Failing is safer and more honest. + +#### Exact `DevicePreparation::EnsureOnlyDisplay` + +Windows can deactivate other displays. A clean public macOS equivalent was not identified. + +For v1: + +- Succeed only if the target is already the only active display. +- Otherwise return `DevicePrepFailed`. + +Do not silently map this to mirroring. Mirroring all displays to the target is not the same behavior as making the target the only active display. + +#### Disconnected Remembered Displays + +Windows can query inactive display paths. macOS public APIs can enumerate active and online displays, but not a robust list of unplugged displays remembered by system settings. + +For v1: + +- Enumerate online displays. +- Treat disconnected displays as unavailable. + +#### Display Audio Context Restoration + +Use `NoopAudioContext` for macOS v1. Changing display topology can affect default audio devices, but implementing audio-device capture/release should be a separate platform effort. + +## Proposed Source Layout + +Add a macOS platform directory parallel to Windows: + +```text +src/macos/ + CMakeLists.txt + types.cpp + json.cpp + json_serializer.cpp + mac_api_layer.cpp + mac_api_utils.cpp + mac_display_device_general.cpp + mac_display_device_modes.cpp + mac_display_device_topology.cpp + mac_display_device_primary.cpp + settings_manager_general.cpp + settings_manager_apply.cpp + settings_manager_revert.cpp + settings_utils.cpp + persistent_state.cpp + include/display_device/macos/ + types.h + json.h + settings_manager.h + persistent_state.h + mac_api_layer.h + mac_api_layer_interface.h + mac_api_utils.h + mac_display_device.h + mac_display_device_interface.h + settings_utils.h + detail/json_serializer.h +``` + +Not every file needs to be fully implemented in phase 0. However, establishing the structure early keeps the backend consistent with Windows. + +## Proposed macOS Internal Types + +### `MacDisplayId` + +Use `CGDirectDisplayID` as the low-level display handle. + +### `MacDisplayMode` + +Represent a display mode using common `DisplayMode` shape: + +```cpp +struct DisplayMode { + Resolution m_resolution; + Rational m_refresh_rate; +}; +``` + +If possible, move `DisplayMode` and `DeviceDisplayModeMap` from Windows-specific types into common once both platforms need them. + +### `ActiveTopology` + +The current Windows `ActiveTopology` type is not inherently Windows-specific. Consider moving it to common after macOS proves it can use the same representation. + +Short term, a macOS-local duplicate may be acceptable, but the better end state is a shared type. + +### `SingleDisplayConfigState` + +The Windows state tracks topology, primary devices, original modes, original HDR states, and original primary device. + +For macOS, use the same conceptual state: + +- Initial topology. +- Initial primary/main device set. +- Modified topology. +- Original display modes. +- Original primary/main device. +- Original HDR state map only if needed to keep JSON shape consistent. + +Open decision: + +- Whether to share `SingleDisplayConfigState` across platforms or keep platform-specific copies. Sharing reduces duplication but may force unsupported HDR fields into macOS state. Keeping copies avoids premature abstraction. + +## Proposed Class Responsibilities + +### `MacApiLayerInterface` + +Lowest-level mockable wrapper around CoreGraphics, CoreFoundation, and IOKit. + +Responsibilities: + +- Enumerate active displays. +- Enumerate online displays. +- Get current display mode. +- Get all display modes. +- Begin/cancel/complete display configuration. +- Configure display mode. +- Configure display origin. +- Configure display mirroring. +- Query display bounds, pixels, main status, active status, online status, mirror status. +- Read IOKit display info dictionary. +- Convert CoreFoundation values to C++ values. +- Produce readable error strings for `CGError` and IOKit failures. + +### `MacDisplayDeviceInterface` + +Higher-level platform service used by `SettingsManager`. + +Responsibilities: + +- Check whether display configuration API access is available. +- Enumerate devices. +- Resolve display names. +- Get and set topology. +- Get and set display modes. +- Query and set primary/main display. +- Query HDR state as unsupported. + +### `SettingsManager` + +Coordinates public API behavior and transaction safety. + +Responsibilities: + +- Validate target devices. +- Compute new topology and mode state. +- Apply supported changes in a safe order. +- Install rollback guards after each successful mutation. +- Persist only after all mutations succeed. +- Revert previously persisted settings. +- Return existing `ApplyResult` and `RevertResult` values honestly. + +## Phase 0: Platform Skeleton + +### Objective + +Build a real macOS platform target with mocked-out behavior, proving the repository can compile and test on macOS without the dummy `APPLE` target. + +### Implementation Tasks + +- Add `src/macos/CMakeLists.txt`. +- Update `src/CMakeLists.txt` so `APPLE` adds `src/macos`. +- Link macOS platform target against: + - `CoreGraphics` + - `CoreFoundation` + - `IOKit` +- Add macOS headers and source stubs. +- Add macOS JSON serialization stubs for platform state. +- Add `tests/unit/macos/CMakeLists.txt`. +- Update `tests/unit/CMakeLists.txt` so `APPLE` adds macOS tests. +- Add mocks for `MacApiLayerInterface` and `MacDisplayDeviceInterface`. + +### Behavior + +- Read-only APIs can return empty values initially. +- Mutating APIs can return failure or unsupported behavior. +- The goal is build/test wiring, not feature completeness. + +### Acceptance Criteria + +- `cmake -G Ninja -B cmake-build-macos -S .` configures on macOS. +- `ninja -C cmake-build-macos` builds. +- `cmake-build-macos/tests/test_libdisplaydevice` runs. +- Doxygen succeeds with all new declarations documented. + +## Phase 1: Read-Only macOS Support + +### Objective + +Implement safe display enumeration and current-state queries without modifying system display settings. + +### Implementation Tasks + +- Implement active display enumeration. +- Implement online display enumeration. +- Implement IOKit display info lookup. +- Implement product/friendly-name extraction. +- Implement EDID extraction and parsing. +- Implement device ID generation. +- Implement current display mode conversion. +- Implement current topology read. +- Implement topology validation and comparison. +- Implement primary/main display query. +- Implement current HDR state query as unsupported. + +### API Coverage + +Supported: + +- `enumAvailableDevices` +- `getDisplayName` +- `getCurrentTopology` +- `isTopologyValid` +- `isTopologyTheSame` +- `getCurrentDisplayModes` +- `isPrimary` +- `getCurrentHdrStates` + +Not supported yet: + +- `setTopology` +- `setDisplayModes` +- `setAsPrimary` +- `setHdrStates` except no-op unsupported handling. +- `applySettings` for real mutations. + +### Testing + +Unit tests: + +- Active display enumeration success. +- Online-but-not-active handling if representable through mocks. +- Product name extraction. +- Missing product name fallback. +- EDID extraction and parse success. +- Missing EDID returns `std::nullopt`. +- Device ID generated from EDID. +- Device ID generated from vendor/product/serial fallback. +- Device ID generated from display ID fallback. +- Resolution uses pixel dimensions. +- Scale uses pixel-to-point ratio. +- Refresh rate conversion handles integer, fractional, and zero values. +- Topology groups mirrored displays. +- Topology comparison ignores group ordering where appropriate. +- HDR state returns `std::nullopt`. + +Live tests: + +- Read-only enumeration returns at least one active display. +- Main display is marked primary. +- Current mode can be read for each active display. + +Live tests should be non-mutating and safe to run by default. + +### Acceptance Criteria + +- A user can enumerate displays on macOS and receive meaningful IDs, names, EDID when available, current pixel resolution, scale, refresh, primary flag, and origin. +- No normal tests mutate display settings. +- Unsupported HDR is represented consistently. + +## Phase 2: Active-Display Mode Changes + +### Objective + +Support resolution and refresh changes on already-active displays, with reliable rollback and persisted revert. + +### Implementation Tasks + +- Implement mode matching by pixel resolution and refresh rate. +- Implement `setDisplayModes`. +- Implement mode guards. +- Implement `SettingsManager::applySettings` for active displays with: + - `VerifyOnly` + - `m_resolution` + - `m_refresh_rate` + - resolution and refresh together +- Implement `SettingsManager::revertSettings` for mode-only changes. +- Persist original display modes before mutation. + +### Mode Selection Rules + +- Filter to modes usable for desktop GUI. +- Prefer exact pixel resolution. +- Prefer exact refresh rate when requested. +- Use fuzzy refresh comparison for common fractional rates. +- If refresh is omitted, preserve current refresh where possible. +- If resolution is omitted, preserve current pixel resolution where possible. +- Re-read current mode after applying and verify it matches the requested mode within tolerance. + +### Transaction Rules + +- Do not persist until the display mode has been applied and verified. +- If persistence fails, roll back the mode change. +- If verification fails, roll back immediately and return failure. +- If rollback fails, log loudly but still return the original operation failure. + +### API Coverage + +Supported: + +- `applySettings` mode changes for active displays. +- `revertSettings` for mode-only persisted state. +- `setDisplayModes`. + +Still unsupported: + +- topology changes. +- primary/main display changes. +- true `EnsureActive`. +- `EnsureOnlyDisplay`. +- HDR writes. + +### Testing + +Unit tests: + +- Mode matching exact resolution and exact refresh. +- Mode matching exact resolution and fuzzy refresh. +- Mode matching with refresh omitted. +- Failure when no usable mode matches. +- Failure when display is inactive. +- Failure when CoreGraphics mode application fails. +- Failure when verification reads back the wrong mode. +- Rollback attempted on verification failure. +- Rollback attempted on persistence failure. +- Persistence skipped when mode already matches. + +Live tests: + +- Opt-in by environment variable, for example `DD_ENABLE_DISPLAY_MUTATION_TESTS=1`. +- Skip if only one mode is available. +- Change to another safe mode, verify, then revert. +- Never run display mutation tests by default in CI. + +### Acceptance Criteria + +- Users can change resolution and/or refresh on an active macOS display and revert through the existing public API. +- Unsupported fields fail before making unrelated display changes. +- Normal tests remain safe. + +## Phase 3: Topology, Main Display, And Mirroring + +### Objective + +Add supported display arrangement changes using CoreGraphics display configuration transactions. + +### Implementation Tasks + +- Implement `setTopology`. +- Implement topology guards. +- Implement `setAsPrimary`. +- Implement primary/main display guards. +- Extend `applySettings` for: + - `EnsurePrimary` + - best-effort `EnsureActive` + - mirroring groups +- Extend `revertSettings` for topology + primary + mode combinations. + +### Topology Rules + +- Each inner topology group represents a mirrored set. +- Each top-level group represents an extended display region. +- A single-device group means the display is extended/unmirrored. +- A multi-device group means secondary displays mirror the group's primary display. + +### Main Display Rules + +- Treat `CGDisplayIsMain` as the primary/main-display verification API. +- Move the target display to origin `(0, 0)` when making it primary. +- Reposition other displays relative to preserve a non-overlapping arrangement. +- Verify after applying. + +### `EnsureActive` Rules + +- If target is active, succeed. +- If target is online but mirrored/non-drawable, attempt to make it drawable. +- If target is disconnected, fail. + +### `EnsureOnlyDisplay` Rules + +- If the target is already the only active display, succeed. +- Otherwise fail with `DevicePrepFailed`. +- Do not silently replace this with mirroring. + +### Testing + +Unit tests: + +- Extended topology conversion. +- Mirrored topology conversion. +- Mixed topology conversion. +- Invalid topology rejection. +- CoreGraphics configuration begin failure. +- Origin configuration failure. +- Mirroring configuration failure. +- Complete configuration failure. +- Verification failure after reported success. +- Rollback after topology failure. +- Primary/main display success. +- Primary/main display verification failure. + +Live tests: + +- Opt-in only. +- Require at least two displays for topology tests. +- Save initial topology before each test. +- Always attempt revert in cleanup. +- Skip mirroring tests if hardware/software mirroring is unavailable or unstable. + +### Acceptance Criteria + +- Supported topology and main-display changes are verified after application. +- Revert restores the original topology/main/mode state for supported scenarios. +- Unsupported topology requests fail predictably. + +## Phase 4: Contract Polish And Documentation + +### Objective + +Make the macOS support story clear to users and maintainers. + +### Implementation Tasks + +- Update README build instructions for macOS. +- Add macOS support matrix to documentation. +- Add Doxygen notes for unsupported HDR and `EnsureOnlyDisplay`. +- Move shared platform-neutral types/helpers into `common` if duplication has emerged. +- Add shared public contract tests for behavior that should pass on all platforms. +- Keep platform-specific tests for OS translation and platform edge cases. + +### Shared Contract Tests + +Possible shared tests: + +- Enumerated device IDs are non-empty and unique. +- Active devices have `m_info`. +- `getDisplayName` returns empty for unknown IDs. +- `resetPersistence` succeeds with empty persistence. +- `revertSettings` succeeds when there is no cached state. +- Applying unsupported settings returns a failure result without persistence changes. + +### Documentation Topics + +- macOS v1 uses public APIs only. +- Resolution is pixel-based. +- Scale is derived from pixel-to-point ratio. +- HDR state is unsupported. +- Disconnected remembered displays are not enumerated. +- `EnsureOnlyDisplay` is not equivalent to macOS mirroring and is unsupported unless already true. +- Display mutation tests are opt-in. + +## Suggested Release Milestones + +### macOS v1a + +Includes: + +- Phase 0. +- Phase 1. +- Phase 2. + +User-visible support: + +- Enumerate macOS displays. +- Query display names and current modes. +- Apply and revert resolution/refresh changes on active displays. +- Explicitly report unsupported HDR and unsupported topology preparation modes. + +This is the first practical release because it provides a useful workflow while avoiding the riskiest arrangement behavior. + +### macOS v1b + +Includes: + +- Phase 3. +- Phase 4 documentation and shared contract polish. + +User-visible support: + +- Main display changes. +- Best-effort active-display preparation. +- Supported mirroring/topology changes. +- Revert across topology/main/mode combinations. + +## Important Design Decisions To Make Before Coding + +1. Should macOS `DisplayMode` and `ActiveTopology` be moved to `common` immediately, or only after macOS mode/topology code exists? +2. Should `applySettings` fail immediately when `m_hdr_state` is supplied, or ignore it when every target reports unsupported HDR? +3. Should `setDisplayModes` choose the closest refresh rate when exact/fuzzy refresh is unavailable, or fail? +4. Should the first mutating implementation use `kCGConfigureForAppOnly` or `kCGConfigureForSession`? +5. What should `m_display_name` be on macOS: a synthetic CoreGraphics display name, a product name, or another logical identifier? +6. Should live display mutation tests be part of the normal test binary but skipped by default, or built behind a separate CMake option? + +## Recommended Defaults + +- Move shared types only after phase 2 proves macOS uses them cleanly. +- Fail when `m_hdr_state` is supplied. +- Fail when no acceptable refresh match exists. +- Use `kCGConfigureForSession` for explicit apply/revert behavior, not permanent settings. +- Use a synthetic logical display name for `m_display_name` and put human names in `m_friendly_name`. +- Include live mutation tests in the test binary, but skip them unless an environment variable opts in. + +## Risks + +### Display ID Stability + +Some virtual displays and adapters may not expose full EDID or serial metadata. The fallback path must be documented and tested. + +### HiDPI Mode Semantics + +macOS mode width/height can mean points or pixels depending on the API. The implementation must consistently use pixel APIs for `Resolution`. + +### Refresh Rate Semantics + +Variable refresh and zero refresh reporting can make exact matching impossible. The implementation should avoid over-promising refresh control. + +### Main Display Behavior + +Moving origin to `(0, 0)` may not always produce the expected main display on every macOS version or setup. Always verify. + +### Mirroring Behavior + +Hardware and software mirroring can change active/online display semantics. Tests must model and verify both where possible. + +### User Disruption + +Display mutation tests can interrupt real workflows. Keep them opt-in and aggressively revert. + +## Verification Commands + +On macOS: + +```bash +cmake -G Ninja -B cmake-build-macos -S . +ninja -C cmake-build-macos +./cmake-build-macos/tests/test_libdisplaydevice +``` + +Opt-in display mutation tests should require an explicit environment variable, for example: + +```bash +DD_ENABLE_DISPLAY_MUTATION_TESTS=1 ./cmake-build-macos/tests/test_libdisplaydevice +``` + +Documentation should also be built because the project treats missing Doxygen documentation as a build failure: + +```bash +ninja -C cmake-build-macos docs +``` From 0cf2f7ac848d32ffa51c520668faf4357d011282 Mon Sep 17 00:00:00 2001 From: marton Date: Thu, 18 Jun 2026 19:07:42 -0400 Subject: [PATCH 02/17] plaform skeleton --- src/CMakeLists.txt | 4 +- src/macos/CMakeLists.txt | 26 ++ .../macos/detail/json_serializer.h | 18 ++ src/macos/include/display_device/macos/json.h | 16 ++ .../display_device/macos/mac_api_layer.h | 96 +++++++ .../macos/mac_api_layer_interface.h | 135 ++++++++++ .../display_device/macos/mac_api_utils.h | 20 ++ .../display_device/macos/mac_display_device.h | 94 +++++++ .../macos/mac_display_device_interface.h | 110 ++++++++ .../display_device/macos/persistent_state.h | 50 ++++ .../display_device/macos/settings_manager.h | 73 +++++ .../display_device/macos/settings_utils.h | 25 ++ .../include/display_device/macos/types.h | 141 ++++++++++ src/macos/json.cpp | 20 ++ src/macos/json_serializer.cpp | 18 ++ src/macos/mac_api_layer.cpp | 94 +++++++ src/macos/mac_api_utils.cpp | 12 + src/macos/mac_display_device_general.cpp | 31 +++ src/macos/mac_display_device_hdr.cpp | 28 ++ src/macos/mac_display_device_modes.cpp | 18 ++ src/macos/mac_display_device_primary.cpp | 18 ++ src/macos/mac_display_device_topology.cpp | 57 ++++ src/macos/persistent_state.cpp | 92 +++++++ src/macos/settings_manager_apply.cpp | 24 ++ src/macos/settings_manager_general.cpp | 64 +++++ src/macos/settings_manager_revert.cpp | 21 ++ src/macos/settings_utils.cpp | 23 ++ src/macos/types.cpp | 12 + tests/unit/CMakeLists.txt | 2 +- tests/unit/macos/CMakeLists.txt | 5 + tests/unit/macos/test_json_converter.cpp | 57 ++++ tests/unit/macos/test_mac_api_layer.cpp | 43 +++ tests/unit/macos/test_mac_api_utils.cpp | 12 + tests/unit/macos/test_mac_display_device.cpp | 91 +++++++ tests/unit/macos/test_persistent_state.cpp | 107 ++++++++ tests/unit/macos/test_settings_manager.cpp | 249 ++++++++++++++++++ tests/unit/macos/test_settings_utils.cpp | 18 ++ tests/unit/macos/utils/mock_mac_api_layer.h | 29 ++ .../macos/utils/mock_mac_display_device.h | 26 ++ 39 files changed, 1975 insertions(+), 4 deletions(-) create mode 100644 src/macos/CMakeLists.txt create mode 100644 src/macos/include/display_device/macos/detail/json_serializer.h create mode 100644 src/macos/include/display_device/macos/json.h create mode 100644 src/macos/include/display_device/macos/mac_api_layer.h create mode 100644 src/macos/include/display_device/macos/mac_api_layer_interface.h create mode 100644 src/macos/include/display_device/macos/mac_api_utils.h create mode 100644 src/macos/include/display_device/macos/mac_display_device.h create mode 100644 src/macos/include/display_device/macos/mac_display_device_interface.h create mode 100644 src/macos/include/display_device/macos/persistent_state.h create mode 100644 src/macos/include/display_device/macos/settings_manager.h create mode 100644 src/macos/include/display_device/macos/settings_utils.h create mode 100644 src/macos/include/display_device/macos/types.h create mode 100644 src/macos/json.cpp create mode 100644 src/macos/json_serializer.cpp create mode 100644 src/macos/mac_api_layer.cpp create mode 100644 src/macos/mac_api_utils.cpp create mode 100644 src/macos/mac_display_device_general.cpp create mode 100644 src/macos/mac_display_device_hdr.cpp create mode 100644 src/macos/mac_display_device_modes.cpp create mode 100644 src/macos/mac_display_device_primary.cpp create mode 100644 src/macos/mac_display_device_topology.cpp create mode 100644 src/macos/persistent_state.cpp create mode 100644 src/macos/settings_manager_apply.cpp create mode 100644 src/macos/settings_manager_general.cpp create mode 100644 src/macos/settings_manager_revert.cpp create mode 100644 src/macos/settings_utils.cpp create mode 100644 src/macos/types.cpp create mode 100644 tests/unit/macos/CMakeLists.txt create mode 100644 tests/unit/macos/test_json_converter.cpp create mode 100644 tests/unit/macos/test_mac_api_layer.cpp create mode 100644 tests/unit/macos/test_mac_api_utils.cpp create mode 100644 tests/unit/macos/test_mac_display_device.cpp create mode 100644 tests/unit/macos/test_persistent_state.cpp create mode 100644 tests/unit/macos/test_settings_manager.cpp create mode 100644 tests/unit/macos/test_settings_utils.cpp create mode 100644 tests/unit/macos/utils/mock_mac_api_layer.h create mode 100644 tests/unit/macos/utils/mock_mac_display_device.h 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/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/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/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..3505480 --- /dev/null +++ b/src/macos/include/display_device/macos/mac_api_layer.h @@ -0,0 +1,96 @@ +/** + * @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::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::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..f483e1f --- /dev/null +++ b/src/macos/include/display_device/macos/mac_api_layer_interface.h @@ -0,0 +1,135 @@ +/** + * @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 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 a logical display name. + * @param display_id Display to query. + * @returns Logical display name 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 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..c1dd8f1 --- /dev/null +++ b/src/macos/include/display_device/macos/mac_api_utils.h @@ -0,0 +1,20 @@ +/** + * @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); +} // 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..cd25bfe --- /dev/null +++ b/src/macos/include/display_device/macos/mac_display_device.h @@ -0,0 +1,94 @@ +/** + * @file src/macos/include/display_device/macos/mac_display_device.h + * @brief Declarations for the MacDisplayDevice. + */ +#pragma once + +// system includes +#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: + 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..4eeb4bf --- /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 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 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..c0b4f83 --- /dev/null +++ b/src/macos/include/display_device/macos/settings_manager.h @@ -0,0 +1,73 @@ +/** + * @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. + */ + 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; + 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..e608a70 --- /dev/null +++ b/src/macos/include/display_device/macos/settings_utils.h @@ -0,0 +1,25 @@ +/** + * @file src/macos/include/display_device/macos/settings_utils.h + * @brief Declarations for macOS settings utility functions. + */ +#pragma once + +// local includes +#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 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..dd3d010 --- /dev/null +++ b/src/macos/include/display_device/macos/types.h @@ -0,0 +1,141 @@ +/** + * @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 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..968f686 --- /dev/null +++ b/src/macos/mac_api_layer.cpp @@ -0,0 +1,94 @@ +/** + * @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 + +namespace display_device { + bool MacApiLayer::isApiAccessAvailable() const { + return true; + } + + std::string MacApiLayer::getErrorString(const MacApiError error_code) const { + std::ostringstream error; + error << "[code: " << error_code << "]"; + return error.str(); + } + + MacDisplayIdList MacApiLayer::getDisplayIds(const MacQueryType type) const { + static_cast(type); + return {}; + } + + std::optional MacApiLayer::getCurrentDisplayMode(const MacDisplayId display_id) const { + static_cast(display_id); + return std::nullopt; + } + + MacDisplayModeList MacApiLayer::getDisplayModes(const MacDisplayId display_id) const { + static_cast(display_id); + return {}; + } + + std::string MacApiLayer::getDisplayName(const MacDisplayId display_id) const { + static_cast(display_id); + return {}; + } + + std::string MacApiLayer::getFriendlyName(const MacDisplayId display_id) const { + static_cast(display_id); + return {}; + } + + std::vector MacApiLayer::getEdid(const MacDisplayId display_id) const { + static_cast(display_id); + return {}; + } + + std::optional MacApiLayer::getDisplayScale(const MacDisplayId display_id) const { + static_cast(display_id); + return std::nullopt; + } + + std::optional MacApiLayer::getOriginPoint(const MacDisplayId display_id) const { + static_cast(display_id); + return std::nullopt; + } + + bool MacApiLayer::isMainDisplay(const MacDisplayId display_id) const { + static_cast(display_id); + return false; + } + + bool MacApiLayer::isActive(const MacDisplayId display_id) const { + static_cast(display_id); + return false; + } + + bool MacApiLayer::isOnline(const MacDisplayId display_id) const { + static_cast(display_id); + return false; + } + + bool MacApiLayer::setDisplayMode(const MacDisplayId display_id, const MacDisplayMode &mode) { + static_cast(display_id); + static_cast(mode); + return false; + } + + 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..a7c5136 --- /dev/null +++ b/src/macos/mac_api_utils.cpp @@ -0,0 +1,12 @@ +/** + * @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" + +namespace display_device::mac_utils { + bool isSuccess(const MacApiError error_code) { + return error_code == 0; + } +} // 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..372ef24 --- /dev/null +++ b/src/macos/mac_display_device_general.cpp @@ -0,0 +1,31 @@ +/** + * @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 + +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 { + return {}; + } + + std::string MacDisplayDevice::getDisplayName(const std::string &device_id) const { + static_cast(device_id); + return {}; + } +} // 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..50470be --- /dev/null +++ b/src/macos/mac_display_device_hdr.cpp @@ -0,0 +1,28 @@ +/** + * @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" + +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) { + for (const auto &[device_id, state] : states) { + static_cast(device_id); + if (state) { + return false; + } + } + + return true; + } +} // 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..8fc5812 --- /dev/null +++ b/src/macos/mac_display_device_modes.cpp @@ -0,0 +1,18 @@ +/** + * @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" + +namespace display_device { + MacDeviceDisplayModeMap MacDisplayDevice::getCurrentDisplayModes(const StringSet &device_ids) const { + static_cast(device_ids); + return {}; + } + + bool MacDisplayDevice::setDisplayModes(const MacDeviceDisplayModeMap &modes) { + static_cast(modes); + return false; + } +} // 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..e587414 --- /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 { + static_cast(device_id); + return false; + } + + 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..a2cc41b --- /dev/null +++ b/src/macos/mac_display_device_topology.cpp @@ -0,0 +1,57 @@ +/** + * @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 { + return {}; + } + + 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..0beadea --- /dev/null +++ b/src/macos/persistent_state.cpp @@ -0,0 +1,92 @@ +/** + * @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/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) { + 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 macOS 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; + } + + 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..cc4783e --- /dev/null +++ b/src/macos/settings_manager_apply.cpp @@ -0,0 +1,24 @@ +/** + * @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" + +namespace display_device { + MacSettingsManager::ApplyResult MacSettingsManager::applySettings(const SingleDisplayConfiguration &config) { + if (!m_dd_api->isApiAccessAvailable()) { + return ApplyResult::ApiTemporarilyUnavailable; + } + + if (config.m_hdr_state) { + return ApplyResult::HdrStatePrepFailed; + } + + if (config.m_resolution || config.m_refresh_rate) { + return ApplyResult::DisplayModePrepFailed; + } + + return ApplyResult::DevicePrepFailed; + } +} // 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..a433f63 --- /dev/null +++ b/src/macos/settings_manager_revert.cpp @@ -0,0 +1,21 @@ +/** + * @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" + +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; + } + + return RevertResult::SwitchingTopologyFailed; + } +} // namespace display_device diff --git a/src/macos/settings_utils.cpp b/src/macos/settings_utils.cpp new file mode 100644 index 0000000..9e07893 --- /dev/null +++ b/src/macos/settings_utils.cpp @@ -0,0 +1,23 @@ +/** + * @file src/macos/settings_utils.cpp + * @brief Definitions for macOS settings utility functions. + */ +// header include +#include "display_device/macos/settings_utils.h" + +namespace display_device::mac_utils { + 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; + } + + 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/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_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..a24f20c --- /dev/null +++ b/tests/unit/macos/test_mac_api_layer.cpp @@ -0,0 +1,43 @@ +// local includes +#include "display_device/macos/mac_api_layer.h" +#include "fixtures/fixtures.h" + +namespace { + // 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_EQ(m_layer.getErrorString(7), "[code: 7]"); +} + +TEST_F_S(QueryStubs) { + EXPECT_TRUE(m_layer.getDisplayIds(display_device::MacQueryType::Active).empty()); + EXPECT_TRUE(m_layer.getDisplayIds(display_device::MacQueryType::Online).empty()); + EXPECT_EQ(m_layer.getCurrentDisplayMode(1), std::nullopt); + EXPECT_TRUE(m_layer.getDisplayModes(1).empty()); + EXPECT_TRUE(m_layer.getDisplayName(1).empty()); + EXPECT_TRUE(m_layer.getFriendlyName(1).empty()); + EXPECT_TRUE(m_layer.getEdid(1).empty()); + EXPECT_EQ(m_layer.getDisplayScale(1), std::nullopt); + EXPECT_EQ(m_layer.getOriginPoint(1), std::nullopt); + EXPECT_FALSE(m_layer.isMainDisplay(1)); + EXPECT_FALSE(m_layer.isActive(1)); + EXPECT_FALSE(m_layer.isOnline(1)); +} + +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..6aef82f --- /dev/null +++ b/tests/unit/macos/test_mac_api_utils.cpp @@ -0,0 +1,12 @@ +// 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)); +} 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..b29040e --- /dev/null +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -0,0 +1,91 @@ +// system includes +#include + +// local includes +#include "display_device/macos/mac_display_device.h" +#include "fixtures/fixtures.h" +#include "utils/mock_mac_api_layer.h" + +namespace { + // Convenience keywords for GMock + using ::testing::HasSubstr; + using ::testing::Return; + using ::testing::StrictMock; + + // Test fixture(s) for this file + class MacDisplayDeviceMocked: public BaseTest { + public: + std::shared_ptr> m_layer {std::make_shared>()}; + display_device::MacDisplayDevice m_mac_dd {m_layer}; + }; + + // Specialized TEST macro(s) for this test file +#define TEST_F_S(...) DD_MAKE_TEST(TEST_F, MacDisplayDeviceMocked, __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(GeneralStubs) { + EXPECT_TRUE(m_mac_dd.enumAvailableDevices().empty()); + EXPECT_TRUE(m_mac_dd.getDisplayName("DeviceId1").empty()); +} + +TEST_F_S(TopologyStubs) { + EXPECT_TRUE(m_mac_dd.getCurrentTopology().empty()); + 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(DisplayModeStubs) { + EXPECT_TRUE(m_mac_dd.getCurrentDisplayModes({"DeviceId1"}).empty()); + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {}}})); +} + +TEST_F_S(PrimaryStubs) { + EXPECT_FALSE(m_mac_dd.isPrimary("DeviceId1")); + 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}})); +} 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..59801e8 --- /dev/null +++ b/tests/unit/macos/test_settings_manager.cpp @@ -0,0 +1,249 @@ +// 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::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; + } + + 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"}, + }} + }; + } + + // 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; + } + + 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} + }; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + EXPECT_CALL(*m_dd_api, enumAvailableDevices()) + .Times(1) + .WillOnce(Return(test_list)); + + EXPECT_EQ(getImpl().enumAvailableDevices(), test_list); +} + +TEST_F_S(GetDisplayName) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + EXPECT_CALL(*m_dd_api, getDisplayName("DeviceId1")) + .Times(1) + .WillOnce(Return("DisplayName1")); + + EXPECT_EQ(getImpl().getDisplayName("DeviceId1"), "DisplayName1"); +} + +TEST_F_S(ResetPersistence, NoPersistence) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + + EXPECT_TRUE(getImpl().resetPersistence()); +} + +TEST_F_S(ResetPersistence, FailedToReset) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(makeState()))); + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_FALSE(getImpl().resetPersistence()); +} + +TEST_F_S(ResetPersistence, PersistenceReset, NoCapturedDevice) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(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) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(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) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + 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) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + 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, DisplayModeUnsupported) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ( + getImpl().applySettings({.m_resolution = display_device::Resolution {1920, 1080}}), + display_device::MacSettingsManager::ApplyResult::DisplayModePrepFailed + ); +} + +TEST_F_S(ApplySettings, DevicePrepUnsupported) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ(getImpl().applySettings({}), display_device::MacSettingsManager::ApplyResult::DevicePrepFailed); +} + +TEST_F_S(RevertSettings, NoPersistence) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + + EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::Ok); +} + +TEST_F_S(RevertSettings, ApiTemporarilyUnavailable) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(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, TopologyUnsupported) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(makeState()))); + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .WillOnce(Return(true)); + + 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..c7ba76f --- /dev/null +++ b/tests/unit/macos/test_settings_utils.cpp @@ -0,0 +1,18 @@ +// local includes +#include "display_device/macos/settings_utils.h" +#include "fixtures/fixtures.h" + +// 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(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..25df52d --- /dev/null +++ b/tests/unit/macos/utils/mock_mac_api_layer.h @@ -0,0 +1,29 @@ +#pragma once + +// system includes +#include + +// local includes +#include "display_device/macos/mac_api_layer_interface.h" + +namespace display_device { + class MockMacApiLayer: public MacApiLayerInterface { + 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, 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(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..23c7eb7 --- /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 { + 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 From cb125cd98e312ef24dcc3d68d47796570b2ac611 Mon Sep 17 00:00:00 2001 From: marton Date: Fri, 19 Jun 2026 11:19:03 -0400 Subject: [PATCH 03/17] read-only macOS APIs --- .../display_device/macos/mac_api_layer.h | 10 + .../macos/mac_api_layer_interface.h | 14 + .../display_device/macos/mac_display_device.h | 8 + src/macos/mac_api_layer.cpp | 611 +++++++++++++++++- src/macos/mac_display_device_general.cpp | 63 +- src/macos/mac_display_device_modes.cpp | 28 +- src/macos/mac_display_device_primary.cpp | 4 +- src/macos/mac_display_device_topology.cpp | 29 +- tests/unit/macos/test_mac_api_layer.cpp | 61 +- tests/unit/macos/test_mac_display_device.cpp | 168 ++++- tests/unit/macos/utils/mock_mac_api_layer.h | 2 + 11 files changed, 944 insertions(+), 54 deletions(-) diff --git a/src/macos/include/display_device/macos/mac_api_layer.h b/src/macos/include/display_device/macos/mac_api_layer.h index 3505480..b867447 100644 --- a/src/macos/include/display_device/macos/mac_api_layer.h +++ b/src/macos/include/display_device/macos/mac_api_layer.h @@ -28,6 +28,11 @@ namespace display_device { */ [[nodiscard]] MacDisplayIdList getDisplayIds(MacQueryType type) const override; + /** + * @copydoc MacApiLayerInterface::getDeviceId + */ + [[nodiscard]] std::string getDeviceId(MacDisplayId display_id) const override; + /** * @copydoc MacApiLayerInterface::getCurrentDisplayMode */ @@ -78,6 +83,11 @@ namespace display_device { */ [[nodiscard]] bool isOnline(MacDisplayId display_id) const override; + /** + * @copydoc MacApiLayerInterface::getMirrorMaster + */ + [[nodiscard]] MacDisplayId getMirrorMaster(MacDisplayId display_id) const override; + /** * @copydoc MacApiLayerInterface::setDisplayMode */ 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 index f483e1f..c4e1644 100644 --- a/src/macos/include/display_device/macos/mac_api_layer_interface.h +++ b/src/macos/include/display_device/macos/mac_api_layer_interface.h @@ -38,6 +38,13 @@ namespace display_device { */ [[nodiscard]] virtual MacDisplayIdList getDisplayIds(MacQueryType type) const = 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. @@ -108,6 +115,13 @@ namespace display_device { */ [[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. diff --git a/src/macos/include/display_device/macos/mac_display_device.h b/src/macos/include/display_device/macos/mac_display_device.h index cd25bfe..e9aeaa8 100644 --- a/src/macos/include/display_device/macos/mac_display_device.h +++ b/src/macos/include/display_device/macos/mac_display_device.h @@ -89,6 +89,14 @@ namespace display_device { [[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(const std::string &device_id, MacQueryType query_type) const; + std::shared_ptr m_m_api; }; } // namespace display_device diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp index 968f686..99357dd 100644 --- a/src/macos/mac_api_layer.cpp +++ b/src/macos/mac_api_layer.cpp @@ -6,72 +6,637 @@ #include "display_device/macos/mac_api_layer.h" // system includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include 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)}; + const auto max_size {CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1}; + std::string result(static_cast(max_size), '\0'); + if (!CFStringGetCString(value, result.data(), max_size, kCFStringEncodingUTF8)) { + return {}; + } + + result.resize(std::strlen(result.c_str())); + return result; + } + + /** + * @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) { + 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) { + std::ostringstream output; + output << "macos-" << prefix << '-' << std::hex << std::setw(16) << std::setfill('0') << hash; + return output.str(); + } + } // namespace + bool MacApiLayer::isApiAccessAvailable() const { - return true; + 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 << "]"; + 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 { - static_cast(type); - return {}; + 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::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 { - static_cast(display_id); - return std::nullopt; + 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 { - static_cast(display_id); - return {}; + 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 { - static_cast(display_id); - return {}; + std::ostringstream name; + name << "macos-display-" << display_id; + return name.str(); } std::string MacApiLayer::getFriendlyName(const MacDisplayId display_id) const { - static_cast(display_id); + if (const auto dictionary {copyDisplayInfo(display_id)}) { + return getProductName(dictionary.get()); + } + return {}; } std::vector MacApiLayer::getEdid(const MacDisplayId display_id) const { - static_cast(display_id); - return {}; + 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 { - static_cast(display_id); - return std::nullopt; + 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 { - static_cast(display_id); - return std::nullopt; + 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 { - static_cast(display_id); - return false; + return CGDisplayIsMain(display_id) != 0; } bool MacApiLayer::isActive(const MacDisplayId display_id) const { - static_cast(display_id); - return false; + return CGDisplayIsActive(display_id) != 0; } bool MacApiLayer::isOnline(const MacDisplayId display_id) const { - static_cast(display_id); - return false; + 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) { diff --git a/src/macos/mac_display_device_general.cpp b/src/macos/mac_display_device_general.cpp index 372ef24..3d3cbf6 100644 --- a/src/macos/mac_display_device_general.cpp +++ b/src/macos/mac_display_device_general.cpp @@ -8,6 +8,9 @@ // 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)} { @@ -21,11 +24,65 @@ namespace display_device { } EnumeratedDeviceList MacDisplayDevice::enumAvailableDevices() const { - return {}; + 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.push_back({device_id, display_name, friendly_name, edid, info}); + } + + return devices; } std::string MacDisplayDevice::getDisplayName(const std::string &device_id) const { - static_cast(device_id); - return {}; + const auto display_id {getDisplayId(device_id, MacQueryType::Online)}; + if (!display_id) { + return {}; + } + + return m_m_api->getDisplayName(*display_id); + } + + std::optional MacDisplayDevice::getDisplayId(const std::string &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_modes.cpp b/src/macos/mac_display_device_modes.cpp index 8fc5812..d14fa44 100644 --- a/src/macos/mac_display_device_modes.cpp +++ b/src/macos/mac_display_device_modes.cpp @@ -5,10 +5,34 @@ // class header include #include "display_device/macos/mac_display_device.h" +// local includes +#include "display_device/logging.h" + namespace display_device { MacDeviceDisplayModeMap MacDisplayDevice::getCurrentDisplayModes(const StringSet &device_ids) const { - static_cast(device_ids); - return {}; + 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) { + 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) { diff --git a/src/macos/mac_display_device_primary.cpp b/src/macos/mac_display_device_primary.cpp index e587414..3cf7822 100644 --- a/src/macos/mac_display_device_primary.cpp +++ b/src/macos/mac_display_device_primary.cpp @@ -7,8 +7,8 @@ namespace display_device { bool MacDisplayDevice::isPrimary(const std::string &device_id) const { - static_cast(device_id); - return false; + 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) { diff --git a/src/macos/mac_display_device_topology.cpp b/src/macos/mac_display_device_topology.cpp index a2cc41b..e4cd574 100644 --- a/src/macos/mac_display_device_topology.cpp +++ b/src/macos/mac_display_device_topology.cpp @@ -10,7 +10,34 @@ namespace display_device { MacActiveTopology MacDisplayDevice::getCurrentTopology() const { - return {}; + 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 : groups) { + topology.push_back(std::move(group.second)); + } + + return topology; } bool MacDisplayDevice::isTopologyValid(const MacActiveTopology &topology) const { diff --git a/tests/unit/macos/test_mac_api_layer.cpp b/tests/unit/macos/test_mac_api_layer.cpp index a24f20c..4a8a421 100644 --- a/tests/unit/macos/test_mac_api_layer.cpp +++ b/tests/unit/macos/test_mac_api_layer.cpp @@ -1,8 +1,15 @@ +// system includes +#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__) @@ -18,22 +25,48 @@ TEST_F_S(IsApiAccessAvailable) { } TEST_F_S(GetErrorString) { - EXPECT_EQ(m_layer.getErrorString(7), "[code: 7]"); + 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_FALSE(m_layer.getDisplayName(display_id).empty()); + 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(QueryStubs) { - EXPECT_TRUE(m_layer.getDisplayIds(display_device::MacQueryType::Active).empty()); - EXPECT_TRUE(m_layer.getDisplayIds(display_device::MacQueryType::Online).empty()); - EXPECT_EQ(m_layer.getCurrentDisplayMode(1), std::nullopt); - EXPECT_TRUE(m_layer.getDisplayModes(1).empty()); - EXPECT_TRUE(m_layer.getDisplayName(1).empty()); - EXPECT_TRUE(m_layer.getFriendlyName(1).empty()); - EXPECT_TRUE(m_layer.getEdid(1).empty()); - EXPECT_EQ(m_layer.getDisplayScale(1), std::nullopt); - EXPECT_EQ(m_layer.getOriginPoint(1), std::nullopt); - EXPECT_FALSE(m_layer.isMainDisplay(1)); - EXPECT_FALSE(m_layer.isActive(1)); - EXPECT_FALSE(m_layer.isOnline(1)); +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) { diff --git a/tests/unit/macos/test_mac_display_device.cpp b/tests/unit/macos/test_mac_display_device.cpp index b29040e..5a31fb1 100644 --- a/tests/unit/macos/test_mac_display_device.cpp +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -4,6 +4,7 @@ // local includes #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 { @@ -38,13 +39,126 @@ TEST_F_S(IsApiAccessAvailable) { EXPECT_FALSE(m_mac_dd.isApiAccessAvailable()); } -TEST_F_S(GeneralStubs) { - EXPECT_TRUE(m_mac_dd.enumAvailableDevices().empty()); - EXPECT_TRUE(m_mac_dd.getDisplayName("DeviceId1").empty()); +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("DisplayName1")); + EXPECT_CALL(*m_layer, getDisplayName(2)) + .Times(1) + .WillOnce(Return("DisplayName2")); + + 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", + "DisplayName1", + "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", + "DisplayName2", + "DisplayName2", + std::nullopt, + std::nullopt} + }; + EXPECT_EQ(m_mac_dd.enumAvailableDevices(), expected_list); } -TEST_F_S(TopologyStubs) { - EXPECT_TRUE(m_mac_dd.getCurrentTopology().empty()); +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("DisplayName2")); + + EXPECT_EQ(m_mac_dd.getDisplayName("DeviceId2"), "DisplayName2"); +} + +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"}})); } @@ -68,13 +182,49 @@ TEST_F_S(IsTopologyTheSame) { EXPECT_TRUE(m_mac_dd.isTopologyTheSame({{"ID_3"}, {"ID_1", "ID_2"}}, {{"ID_2", "ID_1"}, {"ID_3"}})); } -TEST_F_S(DisplayModeStubs) { - EXPECT_TRUE(m_mac_dd.getCurrentDisplayModes({"DeviceId1"}).empty()); +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(SetDisplayModesStub) { EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {}}})); } -TEST_F_S(PrimaryStubs) { - EXPECT_FALSE(m_mac_dd.isPrimary("DeviceId1")); +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")); } diff --git a/tests/unit/macos/utils/mock_mac_api_layer.h b/tests/unit/macos/utils/mock_mac_api_layer.h index 25df52d..37c6610 100644 --- a/tests/unit/macos/utils/mock_mac_api_layer.h +++ b/tests/unit/macos/utils/mock_mac_api_layer.h @@ -12,6 +12,7 @@ namespace display_device { MOCK_METHOD(bool, isApiAccessAvailable, (), (const, override)); MOCK_METHOD(std::string, getErrorString, (MacApiError), (const, override)); MOCK_METHOD(MacDisplayIdList, getDisplayIds, (MacQueryType), (const, 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)); @@ -22,6 +23,7 @@ namespace display_device { 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)); From 9f0eb5d46b7db92e641a5fb42dd7c7c62eb41929 Mon Sep 17 00:00:00 2001 From: marton Date: Fri, 19 Jun 2026 14:57:46 -0400 Subject: [PATCH 04/17] active display-mode changes --- .../display_device/macos/mac_api_utils.h | 16 + .../display_device/macos/settings_utils.h | 60 ++++ src/macos/mac_api_layer.cpp | 40 ++- src/macos/mac_api_utils.cpp | 18 ++ src/macos/mac_display_device_modes.cpp | 87 ++++- src/macos/settings_manager_apply.cpp | 183 ++++++++++- src/macos/settings_manager_revert.cpp | 70 +++- src/macos/settings_utils.cpp | 222 +++++++++++++ tests/unit/macos/test_mac_api_utils.cpp | 15 + tests/unit/macos/test_mac_display_device.cpp | 224 ++++++++++++- tests/unit/macos/test_settings_manager.cpp | 304 +++++++++++++++++- tests/unit/macos/test_settings_utils.cpp | 101 ++++++ 12 files changed, 1323 insertions(+), 17 deletions(-) diff --git a/src/macos/include/display_device/macos/mac_api_utils.h b/src/macos/include/display_device/macos/mac_api_utils.h index c1dd8f1..de71b31 100644 --- a/src/macos/include/display_device/macos/mac_api_utils.h +++ b/src/macos/include/display_device/macos/mac_api_utils.h @@ -17,4 +17,20 @@ namespace display_device::mac_utils { * @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/settings_utils.h b/src/macos/include/display_device/macos/settings_utils.h index e608a70..6cdf4ed 100644 --- a/src/macos/include/display_device/macos/settings_utils.h +++ b/src/macos/include/display_device/macos/settings_utils.h @@ -5,6 +5,7 @@ #pragma once // local includes +#include "mac_display_device_interface.h" #include "types.h" /** @@ -18,6 +19,65 @@ namespace display_device::mac_utils { */ [[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. */ diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp index 99357dd..32b65e7 100644 --- a/src/macos/mac_api_layer.cpp +++ b/src/macos/mac_api_layer.cpp @@ -20,6 +20,10 @@ #include #include +// local includes +#include "display_device/logging.h" +#include "display_device/macos/mac_api_utils.h" + namespace display_device { namespace { /** @@ -640,9 +644,39 @@ namespace display_device { } bool MacApiLayer::setDisplayMode(const MacDisplayId display_id, const MacDisplayMode &mode) { - static_cast(display_id); - static_cast(mode); - return false; + 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) { diff --git a/src/macos/mac_api_utils.cpp b/src/macos/mac_api_utils.cpp index a7c5136..eec9b97 100644 --- a/src/macos/mac_api_utils.cpp +++ b/src/macos/mac_api_utils.cpp @@ -5,8 +5,26 @@ // 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_modes.cpp b/src/macos/mac_display_device_modes.cpp index d14fa44..8ec0a47 100644 --- a/src/macos/mac_display_device_modes.cpp +++ b/src/macos/mac_display_device_modes.cpp @@ -5,10 +5,28 @@ // 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); + }); + } + } // namespace + MacDeviceDisplayModeMap MacDisplayDevice::getCurrentDisplayModes(const StringSet &device_ids) const { if (device_ids.empty()) { DD_LOG(error) << "Device id set is empty!"; @@ -36,7 +54,72 @@ namespace display_device { } bool MacDisplayDevice::setDisplayModes(const MacDeviceDisplayModeMap &modes) { - static_cast(modes); - return false; + 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) { + 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 (!mac_utils::fuzzyCompareModes(*current_mode, mode) && !hasMatchingMode(m_m_api->getDisplayModes(*display_id), 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 << "!"; + if (!changed_modes.empty()) { + static_cast(setDisplayModes(changed_modes)); + } + return false; + } + + const auto verified_mode {m_m_api->getCurrentDisplayMode(display_id)}; + if (!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); + if (!changed_modes.empty()) { + static_cast(setDisplayModes(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/settings_manager_apply.cpp b/src/macos/settings_manager_apply.cpp index cc4783e..1afb1d9 100644 --- a/src/macos/settings_manager_apply.cpp +++ b/src/macos/settings_manager_apply.cpp @@ -5,20 +5,195 @@ // class header include #include "display_device/macos/settings_manager.h" +// system includes +#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)}; + } + } // namespace + MacSettingsManager::ApplyResult MacSettingsManager::applySettings(const SingleDisplayConfiguration &config) { - if (!m_dd_api->isApiAccessAvailable()) { + 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 ApplyResult::ApiTemporarilyUnavailable; } + DD_LOG(info) << "Using the following macOS configuration:\n" + << toJson(config); + if (config.m_hdr_state) { return ApplyResult::HdrStatePrepFailed; } - if (config.m_resolution || config.m_refresh_rate) { - return ApplyResult::DisplayModePrepFailed; + if (config.m_device_prep != SingleDisplayConfiguration::DevicePreparation::VerifyOnly) { + DD_LOG(error) << "macOS phase 2 only supports VerifyOnly device preparation."; + return ApplyResult::DevicePrepFailed; + } + + const auto topology_before_changes {m_dd_api->getCurrentTopology()}; + if (!m_dd_api->isTopologyValid(topology_before_changes)) { + DD_LOG(error) << "Retrieved current macOS topology is invalid:\n" + << toJson(topology_before_changes); + return ApplyResult::DevicePrepFailed; + } + + const auto devices {m_dd_api->enumAvailableDevices()}; + if (devices.empty()) { + DD_LOG(error) << "Failed to enumerate macOS display devices!"; + return ApplyResult::DevicePrepFailed; + } + + const auto &cached_state {m_persistence_state->getState()}; + 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 ApplyResult::DevicePrepFailed; + } + + 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 ApplyResult::DevicePrepFailed; + } + + 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 ApplyResult::DevicePrepFailed; + } + + new_state.m_modified.m_topology = topology_before_changes; + + const auto cached_display_modes {cached_state ? cached_state->m_modified.m_original_modes : MacDeviceDisplayModeMap {}}; + const bool change_required {config.m_resolution || config.m_refresh_rate}; + const bool might_need_to_restore {!cached_display_modes.empty()}; + + bool rollback_modes_on_failure {false}; + MacDeviceDisplayModeMap modes_to_restore; + + if (change_required || might_need_to_restore) { + const auto topology_devices {mac_utils::flattenTopology(new_state.m_modified.m_topology)}; + const auto current_display_modes {m_dd_api->getCurrentDisplayModes(topology_devices)}; + if (current_display_modes.empty()) { + DD_LOG(error) << "Failed to get current macOS display modes!"; + return ApplyResult::DisplayModePrepFailed; + } + + const auto try_change = [this, &rollback_modes_on_failure, &modes_to_restore, ¤t_display_modes](const MacDeviceDisplayModeMap &new_modes) { + if (current_display_modes == new_modes) { + return true; + } + + DD_LOG(info) << "Changing macOS display modes to:\n" + << toJson(new_modes); + if (!m_dd_api->setDisplayModes(new_modes)) { + return false; + } + + const auto mode_keys_view {std::views::keys(new_modes)}; + const StringSet mode_keys {std::begin(mode_keys_view), std::end(mode_keys_view)}; + const auto verified_modes {m_dd_api->getCurrentDisplayModes(mode_keys)}; + if (verified_modes.empty()) { + DD_LOG(error) << "Failed to verify changed macOS display modes!"; + static_cast(m_dd_api->setDisplayModes(current_display_modes)); + return false; + } + + if (current_display_modes != verified_modes) { + rollback_modes_on_failure = true; + modes_to_restore = current_display_modes; + } + + return true; + }; + + if (change_required) { + const auto original_display_modes {cached_display_modes.empty() ? current_display_modes : cached_display_modes}; + const auto new_display_modes { + mac_utils::computeNewDisplayModes(config.m_resolution, config.m_refresh_rate, configuring_primary_devices, device_to_configure, additional_devices_to_configure, original_display_modes) + }; + + if (!try_change(new_display_modes)) { + DD_LOG(error) << "Failed to apply new macOS display modes!"; + return ApplyResult::DisplayModePrepFailed; + } + + new_state.m_modified.m_original_modes = original_display_modes; + } else if (!try_change(cached_display_modes)) { + DD_LOG(error) << "Failed to restore original macOS display modes!"; + return ApplyResult::DisplayModePrepFailed; + } + } + + if (!m_persistence_state->persistState(new_state)) { + DD_LOG(error) << "Failed to persist macOS display settings! Undoing changes..."; + if (rollback_modes_on_failure) { + static_cast(m_dd_api->setDisplayModes(modes_to_restore)); + } + return ApplyResult::PersistenceSaveFailed; } - return ApplyResult::DevicePrepFailed; + return ApplyResult::Ok; } } // namespace display_device diff --git a/src/macos/settings_manager_revert.cpp b/src/macos/settings_manager_revert.cpp index a433f63..3678834 100644 --- a/src/macos/settings_manager_revert.cpp +++ b/src/macos/settings_manager_revert.cpp @@ -5,6 +5,11 @@ // 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()}; @@ -16,6 +21,69 @@ namespace display_device { return RevertResult::ApiTemporarilyUnavailable; } - return RevertResult::SwitchingTopologyFailed; + 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 index 9e07893..7b6278b 100644 --- a/src/macos/settings_utils.cpp +++ b/src/macos/settings_utils.cpp @@ -5,7 +5,124 @@ // header include #include "display_device/macos/settings_utils.h" +// system includes +#include +#include +#include +#include +#include +#include + +// local includes +#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 Remove unavailable devices from a topology. + * @param topology Topology to strip. + * @param devices Currently available devices. + * @return Topology containing only available devices. + */ + [[nodiscard]] MacActiveTopology stripTopology(const MacActiveTopology &topology, const EnumeratedDeviceList &devices) { + const StringSet available_device_ids {getDeviceIds(devices, anyDevice)}; + + MacActiveTopology 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 unavailable device ids. + * @param device_ids Device ids to strip. + * @param devices Currently available devices. + * @return Device ids containing only available devices. + */ + [[nodiscard]] StringSet stripDevices(const StringSet &device_ids, const EnumeratedDeviceList &devices) { + const 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 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) { @@ -17,6 +134,111 @@ namespace display_device::mac_utils { 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 + ) { + 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 macOS device list does not contain any device from the initial state!"; + return std::nullopt; + } + + if (initial_primary_devices.empty()) { + initial_primary_devices = getDeviceIds(devices, primaryOnlyDevices); + if (initial_primary_devices.empty()) { + DD_LOG(error) << "Enumerated macOS device list does not contain primary devices!"; + return std::nullopt; + } + } + + if (initial_state.m_topology != stripped_initial_topology || initial_state.m_primary_devices != initial_primary_devices) { + DD_LOG(warning) << "Adapting macOS initial state because some devices are unavailable.\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 MacSingleDisplayConfigState::Initial { + stripped_initial_topology, + initial_primary_devices + }; + } + + 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. } diff --git a/tests/unit/macos/test_mac_api_utils.cpp b/tests/unit/macos/test_mac_api_utils.cpp index 6aef82f..593ae6d 100644 --- a/tests/unit/macos/test_mac_api_utils.cpp +++ b/tests/unit/macos/test_mac_api_utils.cpp @@ -10,3 +10,18 @@ TEST_S(IsSuccess) { 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 index 5a31fb1..3b09477 100644 --- a/tests/unit/macos/test_mac_display_device.cpp +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -1,7 +1,11 @@ // 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" @@ -10,6 +14,7 @@ namespace { // Convenience keywords for GMock using ::testing::HasSubstr; + using ::testing::InSequence; using ::testing::Return; using ::testing::StrictMock; @@ -20,8 +25,45 @@ namespace { 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)} {} + + /** + * @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) { @@ -206,8 +248,140 @@ TEST_F_S(GetCurrentDisplayModes) { EXPECT_EQ(m_mac_dd.getCurrentDisplayModes({"DeviceId1", "DeviceId2"}), expected_modes); } -TEST_F_S(SetDisplayModesStub) { - EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {}}})); +TEST_F_S(SetDisplayModes, EmptyModes) { + EXPECT_FALSE(m_mac_dd.setDisplayModes({})); +} + +TEST_F_S(SetDisplayModes, InactiveDisplay) { + 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("DeviceId2")); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1920, 1080}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, UnavailableMode) { + 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, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + EXPECT_CALL(*m_layer, getDisplayModes(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}})); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1024, 768}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, AlreadyCurrent) { + 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, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + + EXPECT_TRUE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1920, 1080}, {5985, 100}}}})); +} + +TEST_F_S(SetDisplayModes, Success) { + InSequence sequence; + + 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, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + EXPECT_CALL(*m_layer, getDisplayModes(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}})); + EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1280, 720}, {60, 1}})) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1280, 720}, {60, 1}})); + + EXPECT_TRUE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1280, 720}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, ApplyFailed) { + InSequence sequence; + + 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, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + EXPECT_CALL(*m_layer, getDisplayModes(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}})); + EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1280, 720}, {60, 1}})) + .Times(1) + .WillOnce(Return(false)); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1280, 720}, {60, 1}}}})); +} + +TEST_F_S(SetDisplayModes, VerificationFailedRollsBack) { + InSequence sequence; + + 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, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + EXPECT_CALL(*m_layer, getDisplayModes(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}, {{1920, 1080}, {60, 1}}})); + EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1280, 720}, {60, 1}})) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1024, 768}, {60, 1}})); + 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, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1024, 768}, {60, 1}})); + EXPECT_CALL(*m_layer, getDisplayModes(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayModeList {{{1920, 1080}, {60, 1}}})); + EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1920, 1080}, {60, 1}})) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) + .Times(1) + .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + + EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1280, 720}, {60, 1}}}})); } TEST_F_S(IsPrimary) { @@ -239,3 +413,49 @@ TEST_F_S(HdrStubs) { 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_settings_manager.cpp b/tests/unit/macos/test_settings_manager.cpp index 59801e8..8d808ec 100644 --- a/tests/unit/macos/test_settings_manager.cpp +++ b/tests/unit/macos/test_settings_manager.cpp @@ -13,9 +13,28 @@ namespace { // Convenience keywords for GMock using ::testing::HasSubstr; + using ::testing::InSequence; using ::testing::Return; 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}}}, + }; + std::optional> serializeState(const std::optional &state) { if (state) { bool is_ok {false}; @@ -45,6 +64,32 @@ namespace { }; } + display_device::MacSingleDisplayConfigState makeModeState() { + return { + {DEFAULT_TOPOLOGY, + {"DeviceId1"}}, + {display_device::MacSingleDisplayConfigState::Modified { + DEFAULT_TOPOLOGY, + DEFAULT_MODES, + {}, + {}, + }} + }; + } + + display_device::MacSingleDisplayConfigState makeAppliedModeState() { + return { + {DEFAULT_TOPOLOGY, + {"DeviceId1"}}, + {display_device::MacSingleDisplayConfigState::Modified { + DEFAULT_TOPOLOGY, + DEFAULT_MODES, + {}, + {}, + }} + }; + } + // Test fixture(s) for this file class MacSettingsManagerMocked: public BaseTest { public: @@ -193,17 +238,41 @@ TEST_F_S(ApplySettings, HdrUnsupported) { ); } -TEST_F_S(ApplySettings, DisplayModeUnsupported) { +TEST_F_S(ApplySettings, DisplayModeSuccess) { + const auto expected_state {makeAppliedModeState()}; + EXPECT_CALL(*m_settings_persistence_api, load()) .Times(1) .WillOnce(Return(serializeNoState())); + 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, enumAvailableDevices()) + .Times(1) + .WillOnce(Return(DEFAULT_DEVICES)); + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .WillOnce(Return(DEFAULT_MODES)); + EXPECT_CALL(*m_dd_api, setDisplayModes(CHANGED_MODES)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .WillOnce(Return(CHANGED_MODES)); + EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(expected_state))) + .Times(1) + .WillOnce(Return(true)); EXPECT_EQ( - getImpl().applySettings({.m_resolution = display_device::Resolution {1920, 1080}}), - display_device::MacSettingsManager::ApplyResult::DisplayModePrepFailed + getImpl().applySettings({.m_resolution = display_device::Resolution {1280, 720}}), + display_device::MacSettingsManager::ApplyResult::Ok ); } @@ -215,7 +284,140 @@ TEST_F_S(ApplySettings, DevicePrepUnsupported) { .Times(1) .WillOnce(Return(true)); - EXPECT_EQ(getImpl().applySettings({}), display_device::MacSettingsManager::ApplyResult::DevicePrepFailed); + 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, + {}, + {}, + {}, + }} + }; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + 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, enumAvailableDevices()) + .Times(1) + .WillOnce(Return(DEFAULT_DEVICES)); + EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(expected_state))) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ(getImpl().applySettings({}), display_device::MacSettingsManager::ApplyResult::Ok); +} + +TEST_F_S(ApplySettings, InactiveDevice) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + 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, enumAvailableDevices()) + .Times(1) + .WillOnce(Return(DEFAULT_DEVICES)); + + EXPECT_EQ( + getImpl().applySettings({.m_device_id = "DeviceId3"}), + display_device::MacSettingsManager::ApplyResult::DevicePrepFailed + ); +} + +TEST_F_S(ApplySettings, DisplayModeApplyFailed) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + 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, enumAvailableDevices()) + .Times(1) + .WillOnce(Return(DEFAULT_DEVICES)); + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .WillOnce(Return(DEFAULT_MODES)); + EXPECT_CALL(*m_dd_api, setDisplayModes(CHANGED_MODES)) + .Times(1) + .WillOnce(Return(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()}; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + 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, enumAvailableDevices()) + .Times(1) + .WillOnce(Return(DEFAULT_DEVICES)); + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .WillOnce(Return(DEFAULT_MODES)); + EXPECT_CALL(*m_dd_api, setDisplayModes(CHANGED_MODES)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .WillOnce(Return(CHANGED_MODES)); + EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(expected_state))) + .Times(1) + .WillOnce(Return(false)); + EXPECT_CALL(*m_dd_api, setDisplayModes(DEFAULT_MODES)) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ( + getImpl().applySettings({.m_resolution = display_device::Resolution {1280, 720}}), + display_device::MacSettingsManager::ApplyResult::PersistenceSaveFailed + ); } TEST_F_S(RevertSettings, NoPersistence) { @@ -237,13 +439,105 @@ TEST_F_S(RevertSettings, ApiTemporarilyUnavailable) { EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::ApiTemporarilyUnavailable); } +TEST_F_S(RevertSettings, ModeOnly) { + const display_device::MacDeviceDisplayModeMap current_modes { + {"DeviceId1", {{1280, 720}, {60, 1}}}, + {"DeviceId2", {{2560, 1440}, {120, 1}}}, + }; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(makeModeState()))); + 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(2) + .WillRepeatedly(Return(true)); + EXPECT_CALL(*m_dd_api, isTopologyTheSame(DEFAULT_TOPOLOGY, DEFAULT_TOPOLOGY)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .WillOnce(Return(current_modes)); + EXPECT_CALL(*m_dd_api, setDisplayModes(DEFAULT_MODES)) + .Times(1) + .WillOnce(Return(true)); + 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_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::Ok); +} + +TEST_F_S(RevertSettings, PersistenceFailureRollsBackModes) { + const display_device::MacDeviceDisplayModeMap current_modes { + {"DeviceId1", {{1280, 720}, {60, 1}}}, + {"DeviceId2", {{2560, 1440}, {120, 1}}}, + }; + + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(makeModeState()))); + 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(2) + .WillRepeatedly(Return(true)); + EXPECT_CALL(*m_dd_api, isTopologyTheSame(DEFAULT_TOPOLOGY, DEFAULT_TOPOLOGY)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .WillOnce(Return(current_modes)); + EXPECT_CALL(*m_dd_api, setDisplayModes(DEFAULT_MODES)) + .Times(1) + .WillOnce(Return(true)); + EXPECT_CALL(*m_settings_persistence_api, clear()) + .Times(1) + .WillOnce(Return(false)); + EXPECT_CALL(*m_dd_api, setDisplayModes(current_modes)) + .Times(1) + .WillOnce(Return(true)); + + EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::PersistenceSaveFailed); +} + TEST_F_S(RevertSettings, TopologyUnsupported) { + auto state {makeModeState()}; + state.m_modified.m_topology = {{"DeviceId2"}}; + EXPECT_CALL(*m_settings_persistence_api, load()) .Times(1) - .WillOnce(Return(serializeState(makeState()))); + .WillOnce(Return(serializeState(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 index c7ba76f..2cc5e78 100644 --- a/tests/unit/macos/test_settings_utils.cpp +++ b/tests/unit/macos/test_settings_utils.cpp @@ -1,6 +1,12 @@ // 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__) @@ -13,6 +19,101 @@ TEST_S(FlattenTopology) { ); } +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()); } From da04b9e849cad5130df5f52f23a038530ab5eb7e Mon Sep 17 00:00:00 2001 From: marton Date: Fri, 19 Jun 2026 15:30:46 -0400 Subject: [PATCH 05/17] refactor: update macOS display name handling to use CoreGraphics display ID as capture selector --- MACOS-PLAN.md | 51 +++++++++++++++---- .../settings_manager_interface.h | 4 +- src/common/include/display_device/types.h | 2 +- .../macos/mac_api_layer_interface.h | 4 +- .../macos/mac_display_device_interface.h | 4 +- .../display_device/macos/settings_manager.h | 5 ++ src/macos/mac_api_layer.cpp | 5 +- tests/unit/macos/test_mac_api_layer.cpp | 3 +- tests/unit/macos/test_mac_display_device.cpp | 25 ++++++--- 9 files changed, 76 insertions(+), 27 deletions(-) diff --git a/MACOS-PLAN.md b/MACOS-PLAN.md index 3edf963..c317301 100644 --- a/MACOS-PLAN.md +++ b/MACOS-PLAN.md @@ -42,7 +42,7 @@ macOS can provide useful display enumeration via CoreGraphics and IOKit. Expected fields: - `m_device_id`: generated by this library from best-available stable metadata. -- `m_display_name`: a logical display name, likely derived from the active `CGDirectDisplayID`. +- `m_display_name`: a platform capture token for the display. On macOS v1a this is the decimal `CGDirectDisplayID` string, because the current macOS capture stack selects displays by CoreGraphics display id. - `m_friendly_name`: product name from IOKit display dictionaries when available. - `m_edid`: parsed from `IODisplayEDID` when available. - `m_info`: populated for active/drawable displays. @@ -55,7 +55,7 @@ Expected fields: #### `SettingsManagerInterface::getDisplayName` -Supported for active displays. The display name should be stable enough for logging and display lookup within the current system state, but the durable user-facing identity should remain `m_device_id`. +Supported for online displays. The returned value is a platform capture selector, not a durable user-facing identity. On macOS v1a this should be the decimal `CGDirectDisplayID` string. The durable configuration identity remains `m_device_id`. #### `SettingsManagerInterface::resetPersistence` @@ -563,14 +563,20 @@ Live tests: - Revert restores the original topology/main/mode state for supported scenarios. - Unsupported topology requests fail predictably. -## Phase 4: Contract Polish And Documentation +## Phase 4: Library V1A Hardening ### Objective -Make the macOS support story clear to users and maintainers. +Polish the library-only macOS v1a contract after mode changes are implemented, without adding topology mutation. This phase should make the consumer boundary explicit enough that applications such as Sunshine can integrate the backend without relying on macOS-private behavior or legacy capture identifiers as durable configuration IDs. ### Implementation Tasks +- Define the macOS display identity contract: + - `m_device_id` is the stable best-effort library identity and the value consumers should store in configuration. + - `m_display_name` and `getDisplayName(device_id)` return the transient platform capture selector. + - On macOS v1a, that selector is the decimal `CGDirectDisplayID` string. +- Do not add compatibility aliases for old macOS numeric `output_name` values inside the library. Applications that already exposed raw CoreGraphics ids should migrate or pass through those old settings at their own boundary. +- Add tests for the macOS capture-token contract. - Update README build instructions for macOS. - Add macOS support matrix to documentation. - Add Doxygen notes for unsupported HDR and `EnsureOnlyDisplay`. @@ -585,6 +591,7 @@ Possible shared tests: - Enumerated device IDs are non-empty and unique. - Active devices have `m_info`. - `getDisplayName` returns empty for unknown IDs. +- macOS `getDisplayName` returns the platform capture token for a known stable device id. - `resetPersistence` succeeds with empty persistence. - `revertSettings` succeeds when there is no cached state. - Applying unsupported settings returns a failure result without persistence changes. @@ -594,11 +601,27 @@ Possible shared tests: - macOS v1 uses public APIs only. - Resolution is pixel-based. - Scale is derived from pixel-to-point ratio. +- Configuration should store `m_device_id`; `m_display_name`/`getDisplayName` is a platform capture token and may change across sessions. - HDR state is unsupported. - Disconnected remembered displays are not enumerated. - `EnsureOnlyDisplay` is not equivalent to macOS mirroring and is unsupported unless already true. - Display mutation tests are opt-in. +## Phase 5: Sunshine Consumer Integration + +### Objective + +Wire the accepted macOS library backend into Sunshine once the library work is ready to consume. This is intentionally separate from the library phases because it lives in a different repository/process and may depend on upstream PR acceptance. + +### Implementation Tasks + +- Add a macOS branch to Sunshine's display-device settings-manager factory. +- Use stable library `device_id` values for new Sunshine display-device configuration. +- Keep Sunshine's existing macOS capture/input code consuming the numeric CoreGraphics display id returned by `map_output_name`. +- Handle existing Sunshine macOS configs that stored raw numeric CoreGraphics ids at the Sunshine boundary, either by migration to stable `device_id` or by a narrow pass-through fallback. +- Force, hide, or reject `dd_hdr_option=auto` on macOS until HDR mutation support exists, because the library intentionally fails requested HDR changes. +- Update Sunshine documentation and UI copy so macOS advertises `verify_only` plus resolution/refresh matching and revert, not topology preparation. + ## Suggested Release Milestones ### macOS v1a @@ -614,6 +637,7 @@ User-visible support: - Enumerate macOS displays. - Query display names and current modes. - Apply and revert resolution/refresh changes on active displays. +- Return macOS capture selectors through `getDisplayName` for consumers that need to bind capture to the configured display. - Explicitly report unsupported HDR and unsupported topology preparation modes. This is the first practical release because it provides a useful workflow while avoiding the riskiest arrangement behavior. @@ -622,8 +646,18 @@ This is the first practical release because it provides a useful workflow while Includes: -- Phase 3. -- Phase 4 documentation and shared contract polish. +- Phase 5, when the downstream Sunshine integration is ready. + +User-visible support: + +- Sunshine can use built-in macOS active-display resolution/refresh matching without an external `displayplacer` command. +- Existing Sunshine macOS raw-display-id configs are handled by Sunshine migration/fallback logic, not by expanding the library contract. + +### macOS v2 + +Includes: + +- Phase 3, if topology work is still desired after v1a is proven. User-visible support: @@ -638,8 +672,7 @@ User-visible support: 2. Should `applySettings` fail immediately when `m_hdr_state` is supplied, or ignore it when every target reports unsupported HDR? 3. Should `setDisplayModes` choose the closest refresh rate when exact/fuzzy refresh is unavailable, or fail? 4. Should the first mutating implementation use `kCGConfigureForAppOnly` or `kCGConfigureForSession`? -5. What should `m_display_name` be on macOS: a synthetic CoreGraphics display name, a product name, or another logical identifier? -6. Should live display mutation tests be part of the normal test binary but skipped by default, or built behind a separate CMake option? +5. Should live display mutation tests be part of the normal test binary but skipped by default, or built behind a separate CMake option? ## Recommended Defaults @@ -647,7 +680,7 @@ User-visible support: - Fail when `m_hdr_state` is supplied. - Fail when no acceptable refresh match exists. - Use `kCGConfigureForSession` for explicit apply/revert behavior, not permanent settings. -- Use a synthetic logical display name for `m_display_name` and put human names in `m_friendly_name`. +- Use stable best-effort `m_device_id` values for configuration. Use the platform capture selector for `m_display_name` and put human names in `m_friendly_name`. - Include live mutation tests in the test binary, but skip them unless an environment variable opts in. ## Risks 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/include/display_device/macos/mac_api_layer_interface.h b/src/macos/include/display_device/macos/mac_api_layer_interface.h index c4e1644..1b84a5a 100644 --- a/src/macos/include/display_device/macos/mac_api_layer_interface.h +++ b/src/macos/include/display_device/macos/mac_api_layer_interface.h @@ -60,9 +60,9 @@ namespace display_device { [[nodiscard]] virtual MacDisplayModeList getDisplayModes(MacDisplayId display_id) const = 0; /** - * @brief Get a logical display name. + * @brief Get the macOS capture selector for a display. * @param display_id Display to query. - * @returns Logical display name or empty string if unavailable. + * @returns Decimal CoreGraphics display id string, or empty string if unavailable. */ [[nodiscard]] virtual std::string getDisplayName(MacDisplayId display_id) const = 0; 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 index 4eeb4bf..29f3704 100644 --- a/src/macos/include/display_device/macos/mac_display_device_interface.h +++ b/src/macos/include/display_device/macos/mac_display_device_interface.h @@ -31,9 +31,9 @@ namespace display_device { [[nodiscard]] virtual EnumeratedDeviceList enumAvailableDevices() const = 0; /** - * @brief Get display name associated with the device. + * @brief Get the macOS capture selector 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 not found. + * @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; diff --git a/src/macos/include/display_device/macos/settings_manager.h b/src/macos/include/display_device/macos/settings_manager.h index c0b4f83..d1d09ab 100644 --- a/src/macos/include/display_device/macos/settings_manager.h +++ b/src/macos/include/display_device/macos/settings_manager.h @@ -16,6 +16,11 @@ 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: diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp index 32b65e7..d761b37 100644 --- a/src/macos/mac_api_layer.cpp +++ b/src/macos/mac_api_layer.cpp @@ -19,6 +19,7 @@ #include #include #include +#include // local includes #include "display_device/logging.h" @@ -567,9 +568,7 @@ namespace display_device { } std::string MacApiLayer::getDisplayName(const MacDisplayId display_id) const { - std::ostringstream name; - name << "macos-display-" << display_id; - return name.str(); + return std::to_string(display_id); } std::string MacApiLayer::getFriendlyName(const MacDisplayId display_id) const { diff --git a/tests/unit/macos/test_mac_api_layer.cpp b/tests/unit/macos/test_mac_api_layer.cpp index 4a8a421..526c6e7 100644 --- a/tests/unit/macos/test_mac_api_layer.cpp +++ b/tests/unit/macos/test_mac_api_layer.cpp @@ -1,6 +1,7 @@ // system includes #include #include +#include // local includes #include "display_device/macos/mac_api_layer.h" @@ -43,7 +44,7 @@ TEST_F_S(QueryActiveDisplay) { const auto display_id {active_displays.front()}; EXPECT_FALSE(m_layer.getDeviceId(display_id).empty()); - EXPECT_FALSE(m_layer.getDisplayName(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)); diff --git a/tests/unit/macos/test_mac_display_device.cpp b/tests/unit/macos/test_mac_display_device.cpp index 3b09477..96d48c2 100644 --- a/tests/unit/macos/test_mac_display_device.cpp +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -95,10 +95,10 @@ TEST_F_S(EnumAvailableDevices) { EXPECT_CALL(*m_layer, getDisplayName(1)) .Times(1) - .WillOnce(Return("DisplayName1")); + .WillOnce(Return("1")); EXPECT_CALL(*m_layer, getDisplayName(2)) .Times(1) - .WillOnce(Return("DisplayName2")); + .WillOnce(Return("2")); EXPECT_CALL(*m_layer, getFriendlyName(1)) .Times(1) @@ -136,7 +136,7 @@ TEST_F_S(EnumAvailableDevices) { const display_device::EnumeratedDeviceList expected_list { {"DeviceId1", - "DisplayName1", + "1", "FriendlyName1", ut_consts::DEFAULT_EDID_DATA, display_device::EnumeratedDevice::Info { @@ -148,8 +148,8 @@ TEST_F_S(EnumAvailableDevices) { std::nullopt }}, {"DeviceId2", - "DisplayName2", - "DisplayName2", + "2", + "2", std::nullopt, std::nullopt} }; @@ -168,9 +168,20 @@ TEST_F_S(GetDisplayName) { .WillOnce(Return("DeviceId2")); EXPECT_CALL(*m_layer, getDisplayName(2)) .Times(1) - .WillOnce(Return("DisplayName2")); + .WillOnce(Return("2")); - EXPECT_EQ(m_mac_dd.getDisplayName("DeviceId2"), "DisplayName2"); + 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) { From ec66b2ea1adb8db1c4dc411ab740481aca4e8e85 Mon Sep 17 00:00:00 2001 From: marton Date: Fri, 19 Jun 2026 16:04:26 -0400 Subject: [PATCH 06/17] display power implementation: both mac and windows --- MACOS-PLAN.md | 70 ++++++---- .../display_device/display_power_interface.h | 60 ++++++++ src/macos/display_power.cpp | 132 ++++++++++++++++++ .../display_device/macos/display_power.h | 46 ++++++ .../display_device/macos/mac_api_layer.h | 15 ++ .../macos/mac_api_layer_interface.h | 21 +++ .../include/display_device/macos/types.h | 9 ++ src/macos/mac_api_layer.cpp | 54 +++++++ src/windows/display_power.cpp | 58 ++++++++ .../display_device/windows/display_power.h | 39 ++++++ .../display_device/windows/win_api_layer.h | 15 ++ .../windows/win_api_layer_interface.h | 19 +++ src/windows/win_api_layer.cpp | 37 +++++ tests/unit/macos/test_display_power.cpp | 123 ++++++++++++++++ tests/unit/macos/utils/mock_mac_api_layer.h | 3 + tests/unit/windows/test_display_power.cpp | 75 ++++++++++ tests/unit/windows/utils/mock_win_api_layer.h | 3 + 17 files changed, 752 insertions(+), 27 deletions(-) create mode 100644 src/common/include/display_device/display_power_interface.h create mode 100644 src/macos/display_power.cpp create mode 100644 src/macos/include/display_device/macos/display_power.h create mode 100644 src/windows/display_power.cpp create mode 100644 src/windows/include/display_device/windows/display_power.h create mode 100644 tests/unit/macos/test_display_power.cpp create mode 100644 tests/unit/windows/test_display_power.cpp diff --git a/MACOS-PLAN.md b/MACOS-PLAN.md index c317301..f066464 100644 --- a/MACOS-PLAN.md +++ b/MACOS-PLAN.md @@ -328,9 +328,9 @@ Build a real macOS platform target with mocked-out behavior, proving the reposit ### Acceptance Criteria -- `cmake -G Ninja -B cmake-build-macos -S .` configures on macOS. -- `ninja -C cmake-build-macos` builds. -- `cmake-build-macos/tests/test_libdisplaydevice` runs. +- `cmake -G Ninja -B cmake-build-macos-tests -S .` configures on macOS. +- `cmake --build cmake-build-macos-tests` builds. +- `SKIP_SYSTEM_TESTS=1 ./cmake-build-macos-tests/tests/test_libdisplaydevice` runs the normal non-mutating suite. - Doxygen succeeds with all new declarations documented. ## Phase 1: Read-Only macOS Support @@ -477,7 +477,7 @@ Unit tests: Live tests: -- Opt-in by environment variable, for example `DD_ENABLE_DISPLAY_MUTATION_TESTS=1`. +- Run as system tests; routine verification uses `SKIP_SYSTEM_TESTS=1` to skip them. - Skip if only one mode is available. - Change to another safe mode, verify, then revert. - Never run display mutation tests by default in CI. @@ -607,12 +607,36 @@ Possible shared tests: - `EnsureOnlyDisplay` is not equivalent to macOS mirroring and is unsupported unless already true. - Display mutation tests are opt-in. -## Phase 5: Sunshine Consumer Integration +## Phase 5: Display Power Management Surface + +### Objective + +Add a small cross-platform API for display wake and display-sleep prevention. This is separate from display settings: waking a sleeping display and preventing it from sleeping during capture is not the same thing as topology activation, primary display selection, or mode mutation. + +### Implementation Tasks + +- Add a new public RAII-style API for display capture preparation. +- Support a short wake-for-detection operation before consumers enumerate/select a capture target. +- Support a keep-awake guard whose destructor releases the platform assertion. +- On macOS, implement wake with `IOPMAssertionDeclareUserActivity` and keep-awake with `IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleDisplaySleep, ...)`. +- On Windows, implement wake and keep-awake with `SetThreadExecutionState`, replacing the equivalent Sunshine capture-backend behavior during integration. +- Keep this API independent from `SettingsManagerInterface::applySettings`; `DevicePreparation::EnsureActive` should continue to mean topology/display-configuration activation, not display power state. +- Add Doxygen documentation that clarifies failure behavior and lifetime ownership. + +### Testing + +- Unit-test guard lifetime and release behavior using platform API mocks. +- Unit-test wake retry/timeout behavior without requiring real display sleep. +- Keep real display-sleep tests manual or system-only; normal CI should not try to put a physical monitor to sleep. + +## Phase 6: Sunshine Consumer Integration ### Objective Wire the accepted macOS library backend into Sunshine once the library work is ready to consume. This is intentionally separate from the library phases because it lives in a different repository/process and may depend on upstream PR acceptance. +This phase should also move Sunshine's existing display wake / keep-awake behavior onto library-owned power-management APIs. Sunshine already handles this explicitly on Windows in its capture backend, so the integration should avoid leaving Windows on one path and macOS on another. + ### Implementation Tasks - Add a macOS branch to Sunshine's display-device settings-manager factory. @@ -620,6 +644,7 @@ Wire the accepted macOS library backend into Sunshine once the library work is r - Keep Sunshine's existing macOS capture/input code consuming the numeric CoreGraphics display id returned by `map_output_name`. - Handle existing Sunshine macOS configs that stored raw numeric CoreGraphics ids at the Sunshine boundary, either by migration to stable `device_id` or by a narrow pass-through fallback. - Force, hide, or reject `dd_hdr_option=auto` on macOS until HDR mutation support exists, because the library intentionally fails requested HDR changes. +- Replace Sunshine's platform-local display wake / keep-awake code with the new library API on both macOS and Windows. - Update Sunshine documentation and UI copy so macOS advertises `verify_only` plus resolution/refresh matching and revert, not topology preparation. ## Suggested Release Milestones @@ -646,12 +671,14 @@ This is the first practical release because it provides a useful workflow while Includes: -- Phase 5, when the downstream Sunshine integration is ready. +- Phase 5, when the display power API is accepted. +- Phase 6, when the downstream Sunshine integration is ready. User-visible support: - Sunshine can use built-in macOS active-display resolution/refresh matching without an external `displayplacer` command. - Existing Sunshine macOS raw-display-id configs are handled by Sunshine migration/fallback logic, not by expanding the library contract. +- Sunshine uses the library display-power API to wake and keep displays awake on both macOS and Windows. ### macOS v2 @@ -666,22 +693,15 @@ User-visible support: - Supported mirroring/topology changes. - Revert across topology/main/mode combinations. -## Important Design Decisions To Make Before Coding - -1. Should macOS `DisplayMode` and `ActiveTopology` be moved to `common` immediately, or only after macOS mode/topology code exists? -2. Should `applySettings` fail immediately when `m_hdr_state` is supplied, or ignore it when every target reports unsupported HDR? -3. Should `setDisplayModes` choose the closest refresh rate when exact/fuzzy refresh is unavailable, or fail? -4. Should the first mutating implementation use `kCGConfigureForAppOnly` or `kCGConfigureForSession`? -5. Should live display mutation tests be part of the normal test binary but skipped by default, or built behind a separate CMake option? - -## Recommended Defaults +## Resolved Design Defaults -- Move shared types only after phase 2 proves macOS uses them cleanly. +- Move shared types only after macOS needs them cleanly across multiple implemented features. - Fail when `m_hdr_state` is supplied. - Fail when no acceptable refresh match exists. - Use `kCGConfigureForSession` for explicit apply/revert behavior, not permanent settings. - Use stable best-effort `m_device_id` values for configuration. Use the platform capture selector for `m_display_name` and put human names in `m_friendly_name`. -- Include live mutation tests in the test binary, but skip them unless an environment variable opts in. +- Keep live/system mutation tests in the normal test binary, but run routine verification with `SKIP_SYSTEM_TESTS=1`. +- Keep display power management separate from settings application; do not model display sleep as `DevicePreparation::EnsureActive`. ## Risks @@ -714,19 +734,15 @@ Display mutation tests can interrupt real workflows. Keep them opt-in and aggres On macOS: ```bash -cmake -G Ninja -B cmake-build-macos -S . -ninja -C cmake-build-macos -./cmake-build-macos/tests/test_libdisplaydevice -``` - -Opt-in display mutation tests should require an explicit environment variable, for example: - -```bash -DD_ENABLE_DISPLAY_MUTATION_TESTS=1 ./cmake-build-macos/tests/test_libdisplaydevice +cmake -G Ninja -B cmake-build-macos-tests -S . +cmake --build cmake-build-macos-tests +SKIP_SYSTEM_TESTS=1 ./cmake-build-macos-tests/tests/test_libdisplaydevice +SKIP_SYSTEM_TESTS=1 ctest --test-dir cmake-build-macos-tests --output-on-failure ``` Documentation should also be built because the project treats missing Doxygen documentation as a build failure: ```bash -ninja -C cmake-build-macos docs +cmake -S . -B cmake-build-macos-docs -G Ninja -DBUILD_DOCS=ON -DBUILD_TESTS=OFF +cmake --build cmake-build-macos-docs ``` 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..7f42139 --- /dev/null +++ b/src/common/include/display_device/display_power_interface.h @@ -0,0 +1,60 @@ +/** + * @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. + * + * @param display_name Platform capture selector returned by SettingsManagerInterface::getDisplayName. + * @param timeout Maximum time to wait for the display to become detectable. + * @returns True if the platform wake operation succeeded and the display is considered detectable, 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/macos/display_power.cpp b/src/macos/display_power.cpp new file mode 100644 index 0000000..49577b1 --- /dev/null +++ b/src/macos/display_power.cpp @@ -0,0 +1,132 @@ +/** + * @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} {} + + /** + * @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) { + 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) { + 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)}) { + 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/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/mac_api_layer.h b/src/macos/include/display_device/macos/mac_api_layer.h index b867447..60fbbc4 100644 --- a/src/macos/include/display_device/macos/mac_api_layer.h +++ b/src/macos/include/display_device/macos/mac_api_layer.h @@ -28,6 +28,21 @@ namespace display_device { */ [[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 */ 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 index 1b84a5a..6d77e2c 100644 --- a/src/macos/include/display_device/macos/mac_api_layer_interface.h +++ b/src/macos/include/display_device/macos/mac_api_layer_interface.h @@ -38,6 +38,27 @@ namespace display_device { */ [[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. diff --git a/src/macos/include/display_device/macos/types.h b/src/macos/include/display_device/macos/types.h index dd3d010..2117840 100644 --- a/src/macos/include/display_device/macos/types.h +++ b/src/macos/include/display_device/macos/types.h @@ -28,6 +28,15 @@ namespace display_device { */ 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. */ diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp index d761b37..9f7ce42 100644 --- a/src/macos/mac_api_layer.cpp +++ b/src/macos/mac_api_layer.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -221,6 +222,15 @@ namespace display_device { 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. @@ -513,6 +523,50 @@ namespace display_device { 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()) { diff --git a/src/windows/display_power.cpp b/src/windows/display_power.cpp new file mode 100644 index 0000000..5c77871 --- /dev/null +++ b/src/windows/display_power.cpp @@ -0,0 +1,58 @@ +/** + * @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)} {} + + /** + * @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..4549aeb 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,25 @@ namespace display_device { */ [[nodiscard]] virtual std::optional queryDisplayConfig(QueryType type) const = 0; + /** + * @brief Ask Windows to wake the display and wait briefly for detection. + * @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/win_api_layer.cpp b/src/windows/win_api_layer.cpp index c6561ca..fc32a29 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,41 @@ namespace display_device { return PathAndModeData {paths, modes}; } + bool WinApiLayer::wakeDisplay(const std::chrono::milliseconds timeout) { + const auto result {SetThreadExecutionState(ES_DISPLAY_REQUIRED)}; + if (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() { + const auto result {SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED)}; + if (result == 0) { + DD_LOG(error) << getErrorString(static_cast(GetLastError())) << " failed to request display keep-awake."; + return false; + } + + return true; + } + + bool WinApiLayer::restorePowerRequest() { + const auto result {SetThreadExecutionState(ES_CONTINUOUS)}; + if (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/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/utils/mock_mac_api_layer.h b/tests/unit/macos/utils/mock_mac_api_layer.h index 37c6610..df9da95 100644 --- a/tests/unit/macos/utils/mock_mac_api_layer.h +++ b/tests/unit/macos/utils/mock_mac_api_layer.h @@ -12,6 +12,9 @@ namespace display_device { 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)); 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..b3a3db2 100644 --- a/tests/unit/windows/utils/mock_win_api_layer.h +++ b/tests/unit/windows/utils/mock_win_api_layer.h @@ -11,6 +11,9 @@ namespace display_device { 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)); From 7cd1e9aaf9f870af5a83f141fe369ddd74e8ba53 Mon Sep 17 00:00:00 2001 From: marton Date: Fri, 19 Jun 2026 17:57:16 -0400 Subject: [PATCH 07/17] power management doc clarification --- .../include/display_device/display_power_interface.h | 10 ++++++++-- .../display_device/windows/win_api_layer_interface.h | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/common/include/display_device/display_power_interface.h b/src/common/include/display_device/display_power_interface.h index 7f42139..5d665c2 100644 --- a/src/common/include/display_device/display_power_interface.h +++ b/src/common/include/display_device/display_power_interface.h @@ -43,9 +43,15 @@ namespace display_device { /** * @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 for the display to become detectable. - * @returns True if the platform wake operation succeeded and the display is considered detectable, false otherwise. + * @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; 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 4549aeb..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 @@ -44,7 +44,11 @@ namespace display_device { [[nodiscard]] virtual std::optional queryDisplayConfig(QueryType type) const = 0; /** - * @brief Ask Windows to wake the display and wait briefly for detection. + * @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. */ From 3ca36333899346bdc29484077367648812dd35b7 Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 19:12:26 -0400 Subject: [PATCH 08/17] address strlen security concern --- src/macos/mac_api_layer.cpp | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp index 9f7ce42..3e0bcf7 100644 --- a/src/macos/mac_api_layer.cpp +++ b/src/macos/mac_api_layer.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -212,13 +211,25 @@ namespace display_device { } const auto length {CFStringGetLength(value)}; - const auto max_size {CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1}; - std::string result(static_cast(max_size), '\0'); - if (!CFStringGetCString(value, result.data(), max_size, kCFStringEncodingUTF8)) { + 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 {}; } - result.resize(std::strlen(result.c_str())); return result; } From 78e750bef7e6cd2722ea7f65ecd8eda707ec4e0e Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 19:14:55 -0400 Subject: [PATCH 09/17] RAII constructor/operator deletion --- src/macos/display_power.cpp | 5 +++++ src/windows/display_power.cpp | 5 +++++ tests/unit/macos/test_mac_display_device.cpp | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/src/macos/display_power.cpp b/src/macos/display_power.cpp index 49577b1..9030a42 100644 --- a/src/macos/display_power.cpp +++ b/src/macos/display_power.cpp @@ -63,6 +63,11 @@ namespace display_device { 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. */ diff --git a/src/windows/display_power.cpp b/src/windows/display_power.cpp index 5c77871..4c498e3 100644 --- a/src/windows/display_power.cpp +++ b/src/windows/display_power.cpp @@ -23,6 +23,11 @@ namespace display_device { 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. */ diff --git a/tests/unit/macos/test_mac_display_device.cpp b/tests/unit/macos/test_mac_display_device.cpp index 96d48c2..c97d8a1 100644 --- a/tests/unit/macos/test_mac_display_device.cpp +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -49,6 +49,11 @@ namespace { 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. */ From 8f33f094c587b2264ec5f5e5bae70941fd033d8c Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 19:25:34 -0400 Subject: [PATCH 10/17] use has_value for optionals --- src/macos/display_power.cpp | 6 +++--- src/macos/mac_api_layer.cpp | 2 +- src/macos/mac_display_device_general.cpp | 2 +- src/macos/mac_display_device_modes.cpp | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/macos/display_power.cpp b/src/macos/display_power.cpp index 9030a42..7ce39eb 100644 --- a/src/macos/display_power.cpp +++ b/src/macos/display_power.cpp @@ -94,7 +94,7 @@ namespace display_device { } const auto assertion_id {m_m_api->declareUserActivity(std::string {DISPLAY_DETECTION_REASON})}; - if (!assertion_id) { + if (!assertion_id.has_value()) { return false; } @@ -119,7 +119,7 @@ namespace display_device { std::unique_ptr MacDisplayPower::keepDisplayAwake(const std::string &reason) { const auto assertion_id {m_m_api->createDisplaySleepAssertion(reason)}; - if (!assertion_id) { + if (!assertion_id.has_value()) { return nullptr; } @@ -128,7 +128,7 @@ namespace display_device { 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)}) { + if (const auto display_id {parseDisplayId(display_name)}; display_id.has_value()) { return std::ranges::find(active_displays, *display_id) != std::end(active_displays); } diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp index 3e0bcf7..02e75cd 100644 --- a/src/macos/mac_api_layer.cpp +++ b/src/macos/mac_api_layer.cpp @@ -337,7 +337,7 @@ namespace display_device { int score {0}; const auto check_number = [&score](const std::optional &value, const std::uint32_t expected, const int weight) { - if (!value) { + if (!value.has_value()) { return true; } diff --git a/src/macos/mac_display_device_general.cpp b/src/macos/mac_display_device_general.cpp index 3d3cbf6..a10e110 100644 --- a/src/macos/mac_display_device_general.cpp +++ b/src/macos/mac_display_device_general.cpp @@ -65,7 +65,7 @@ namespace display_device { std::string MacDisplayDevice::getDisplayName(const std::string &device_id) const { const auto display_id {getDisplayId(device_id, MacQueryType::Online)}; - if (!display_id) { + if (!display_id.has_value()) { return {}; } diff --git a/src/macos/mac_display_device_modes.cpp b/src/macos/mac_display_device_modes.cpp index 8ec0a47..b1e2c79 100644 --- a/src/macos/mac_display_device_modes.cpp +++ b/src/macos/mac_display_device_modes.cpp @@ -36,7 +36,7 @@ namespace display_device { MacDeviceDisplayModeMap current_modes; for (const auto &device_id : device_ids) { const auto display_id {getDisplayId(device_id, MacQueryType::Active)}; - if (!display_id) { + if (!display_id.has_value()) { DD_LOG(error) << "Failed to find active macOS display for " << device_id << "!"; return {}; } @@ -68,7 +68,7 @@ namespace display_device { } const auto display_id {getDisplayId(device_id, MacQueryType::Active)}; - if (!display_id) { + if (!display_id.has_value()) { DD_LOG(error) << "Failed to find active macOS display for " << device_id << "!"; return false; } From 4fde62007db495793a5f4a7bffb216739f98a5af Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 19:42:12 -0400 Subject: [PATCH 11/17] remove macos-plan.md --- MACOS-PLAN.md | 748 -------------------------------------------------- 1 file changed, 748 deletions(-) delete mode 100644 MACOS-PLAN.md diff --git a/MACOS-PLAN.md b/MACOS-PLAN.md deleted file mode 100644 index f066464..0000000 --- a/MACOS-PLAN.md +++ /dev/null @@ -1,748 +0,0 @@ -# macOS Implementation Plan - -## Goals - -Add macOS support to `libdisplaydevice` using public macOS APIs only. The first usable macOS release should support safe read-only enumeration and active-display resolution/refresh changes with reliable revert behavior. More complex display arrangement behavior can follow once the basic platform backend is proven. - -The implementation should preserve the existing public library contract where macOS can honestly support it, and fail explicitly where public macOS APIs do not provide equivalent behavior. - -## Non-goals - -- Do not use private or reverse-engineered macOS APIs. -- Do not promise Windows feature parity where macOS cannot provide it. -- Do not make permanent display configuration changes in the initial implementation. -- Do not implement display audio-device restoration in macOS v1. -- Do not implement per-display HDR enable/disable unless a public display-level API is identified. - -## Current Project Shape - -The repository already has a useful platform split: - -- `src/common` contains shared public types, persistence interfaces, logging, JSON helpers, and other platform-neutral pieces. -- `src/windows` contains the Windows platform implementation. -- `src/CMakeLists.txt` already selects `windows` on `WIN32`, and currently installs a dummy interface target for `APPLE`. -- The public top-level library target is `libdisplaydevice::display_device`, which links `libdisplaydevice::common` plus `libdisplaydevice::platform`. - -The Windows implementation uses three layers that should be mirrored on macOS: - -- A low-level, mockable OS API wrapper. -- A higher-level display-device service. -- A `SettingsManager` that coordinates apply/revert transactions and persistence. - -Keeping the same shape should make the macOS backend testable without requiring real monitor rearrangement during normal unit tests. - -## Public API Support Matrix - -### Easy To Support - -#### `SettingsManagerInterface::enumAvailableDevices` - -macOS can provide useful display enumeration via CoreGraphics and IOKit. - -Expected fields: - -- `m_device_id`: generated by this library from best-available stable metadata. -- `m_display_name`: a platform capture token for the display. On macOS v1a this is the decimal `CGDirectDisplayID` string, because the current macOS capture stack selects displays by CoreGraphics display id. -- `m_friendly_name`: product name from IOKit display dictionaries when available. -- `m_edid`: parsed from `IODisplayEDID` when available. -- `m_info`: populated for active/drawable displays. -- `m_info.m_resolution`: pixel resolution. -- `m_info.m_resolution_scale`: derived from pixel dimensions and point bounds. -- `m_info.m_refresh_rate`: current display mode refresh rate. -- `m_info.m_primary`: true when `CGDisplayIsMain(display)` is true. -- `m_info.m_origin_point`: display bounds origin. -- `m_info.m_hdr_state`: `std::nullopt` for v1. - -#### `SettingsManagerInterface::getDisplayName` - -Supported for online displays. The returned value is a platform capture selector, not a durable user-facing identity. On macOS v1a this should be the decimal `CGDirectDisplayID` string. The durable configuration identity remains `m_device_id`. - -#### `SettingsManagerInterface::resetPersistence` - -Straightforward once macOS persistence state exists. This should behave like Windows: if no state is cached, return success; otherwise clear persisted state and release any captured context. macOS v1 will use `NoopAudioContext`. - -#### `applySettings` With `DevicePreparation::VerifyOnly` - -Supported when the target device is already active. This is the safest first mutating path because it does not require topology changes. - -#### `applySettings` With Resolution And Refresh Rate For Active Displays - -Supported through CoreGraphics display modes. The implementation should only select modes that are usable for the desktop GUI and should verify the current display mode after applying changes. - -#### `revertSettings` For Supported Mutations - -Supported for any state that macOS v1 knows how to restore. In early phases, that means mode-only changes. Later, it can include main-display and topology/mirroring changes. - -### Hard But Feasible - -#### Stable Device IDs - -`CGDirectDisplayID` is convenient but not enough by itself. Generate IDs by hashing the most stable available data: - -1. EDID bytes when available. -2. IOKit vendor, product, serial, serial string, and display location when available. -3. CoreGraphics vendor, model, serial, unit number, and display id as fallback. - -The fallback should be documented as less stable. This mirrors the Windows implementation's philosophy: produce a persistent ID when possible and a best-effort ID when the OS/device does not expose enough information. - -#### Resolution Scale - -macOS has point dimensions and pixel dimensions, especially with HiDPI modes. The common `Resolution` type should remain pixel-based. Scale can be computed from current pixel dimensions divided by CoreGraphics point bounds. - -Open decision: - -- Whether scale should be represented as one axis, both axes, or width-only. The existing type has one `FloatingPoint`, so width-based scale is probably the least surprising match for Windows. - -#### Refresh Rate Matching - -Some modes may report `0`, rounded values, or variable refresh behavior. Mode selection should use fuzzy comparison and should prefer exact resolution first, then nearest refresh rate. - -Open decision: - -- If the requested refresh rate is unavailable but a mode with matching resolution exists, should macOS fail or choose the closest refresh? Windows currently uses a relaxed pass and then a stricter pass. A similar two-step strategy is reasonable. - -#### `DevicePreparation::EnsureActive` - -This is feasible only for displays that are online and can be made drawable through public display configuration APIs. It is not feasible for disconnected remembered displays. - -For v1: - -- Succeed if the display is already active. -- Succeed if the display is online and CoreGraphics can make it active by unmirroring or reconfiguring it. -- Fail cleanly if the display is not online. - -#### `DevicePreparation::EnsurePrimary` - -Likely feasible by moving the target display to origin `(0, 0)` and verifying `CGDisplayIsMain(display)`. This needs real-device testing because macOS main-display behavior is tied to display arrangement and menu bar ownership. - -For v1: - -- Support active displays first. -- Verify after applying. -- Roll back if verification fails. - -#### Mirroring And Topology Groups - -CoreGraphics supports mirroring through `CGConfigureDisplayMirrorOfDisplay`. This can map to `ActiveTopology` groups: - -- `{{A}, {B}}`: extended displays. -- `{{A, B}}`: mirrored displays. -- `{{A, B}, {C}}`: mixed mirrored plus extended topology. - -This is hard because CoreGraphics distinguishes active and online displays, and hardware/software mirroring can affect what is drawable. It should be implemented after mode-only changes are stable. - -### Not Feasible With Public APIs - -#### HDR Enable/Disable - -Do not implement HDR write support in macOS v1. Public APIs expose HDR/EDR concepts for rendering and playback, but no public per-display equivalent to Windows advanced color state was identified. - -Expected behavior: - -- Enumeration sets `m_hdr_state` to `std::nullopt`. -- `getCurrentHdrStates` returns each requested device with `std::nullopt`. -- `setHdrStates` returns true only if all requested states are `std::nullopt` or the map has no actual requested changes. -- `applySettings` with `m_hdr_state` should return `HdrStatePrepFailed` before making unrelated changes, unless the implementation can prove no device supports a meaningful HDR mutation. - -Open decision: - -- Whether `applySettings` should fail immediately when `m_hdr_state` is supplied, or ignore it when all target devices report `std::nullopt`. Failing is safer and more honest. - -#### Exact `DevicePreparation::EnsureOnlyDisplay` - -Windows can deactivate other displays. A clean public macOS equivalent was not identified. - -For v1: - -- Succeed only if the target is already the only active display. -- Otherwise return `DevicePrepFailed`. - -Do not silently map this to mirroring. Mirroring all displays to the target is not the same behavior as making the target the only active display. - -#### Disconnected Remembered Displays - -Windows can query inactive display paths. macOS public APIs can enumerate active and online displays, but not a robust list of unplugged displays remembered by system settings. - -For v1: - -- Enumerate online displays. -- Treat disconnected displays as unavailable. - -#### Display Audio Context Restoration - -Use `NoopAudioContext` for macOS v1. Changing display topology can affect default audio devices, but implementing audio-device capture/release should be a separate platform effort. - -## Proposed Source Layout - -Add a macOS platform directory parallel to Windows: - -```text -src/macos/ - CMakeLists.txt - types.cpp - json.cpp - json_serializer.cpp - mac_api_layer.cpp - mac_api_utils.cpp - mac_display_device_general.cpp - mac_display_device_modes.cpp - mac_display_device_topology.cpp - mac_display_device_primary.cpp - settings_manager_general.cpp - settings_manager_apply.cpp - settings_manager_revert.cpp - settings_utils.cpp - persistent_state.cpp - include/display_device/macos/ - types.h - json.h - settings_manager.h - persistent_state.h - mac_api_layer.h - mac_api_layer_interface.h - mac_api_utils.h - mac_display_device.h - mac_display_device_interface.h - settings_utils.h - detail/json_serializer.h -``` - -Not every file needs to be fully implemented in phase 0. However, establishing the structure early keeps the backend consistent with Windows. - -## Proposed macOS Internal Types - -### `MacDisplayId` - -Use `CGDirectDisplayID` as the low-level display handle. - -### `MacDisplayMode` - -Represent a display mode using common `DisplayMode` shape: - -```cpp -struct DisplayMode { - Resolution m_resolution; - Rational m_refresh_rate; -}; -``` - -If possible, move `DisplayMode` and `DeviceDisplayModeMap` from Windows-specific types into common once both platforms need them. - -### `ActiveTopology` - -The current Windows `ActiveTopology` type is not inherently Windows-specific. Consider moving it to common after macOS proves it can use the same representation. - -Short term, a macOS-local duplicate may be acceptable, but the better end state is a shared type. - -### `SingleDisplayConfigState` - -The Windows state tracks topology, primary devices, original modes, original HDR states, and original primary device. - -For macOS, use the same conceptual state: - -- Initial topology. -- Initial primary/main device set. -- Modified topology. -- Original display modes. -- Original primary/main device. -- Original HDR state map only if needed to keep JSON shape consistent. - -Open decision: - -- Whether to share `SingleDisplayConfigState` across platforms or keep platform-specific copies. Sharing reduces duplication but may force unsupported HDR fields into macOS state. Keeping copies avoids premature abstraction. - -## Proposed Class Responsibilities - -### `MacApiLayerInterface` - -Lowest-level mockable wrapper around CoreGraphics, CoreFoundation, and IOKit. - -Responsibilities: - -- Enumerate active displays. -- Enumerate online displays. -- Get current display mode. -- Get all display modes. -- Begin/cancel/complete display configuration. -- Configure display mode. -- Configure display origin. -- Configure display mirroring. -- Query display bounds, pixels, main status, active status, online status, mirror status. -- Read IOKit display info dictionary. -- Convert CoreFoundation values to C++ values. -- Produce readable error strings for `CGError` and IOKit failures. - -### `MacDisplayDeviceInterface` - -Higher-level platform service used by `SettingsManager`. - -Responsibilities: - -- Check whether display configuration API access is available. -- Enumerate devices. -- Resolve display names. -- Get and set topology. -- Get and set display modes. -- Query and set primary/main display. -- Query HDR state as unsupported. - -### `SettingsManager` - -Coordinates public API behavior and transaction safety. - -Responsibilities: - -- Validate target devices. -- Compute new topology and mode state. -- Apply supported changes in a safe order. -- Install rollback guards after each successful mutation. -- Persist only after all mutations succeed. -- Revert previously persisted settings. -- Return existing `ApplyResult` and `RevertResult` values honestly. - -## Phase 0: Platform Skeleton - -### Objective - -Build a real macOS platform target with mocked-out behavior, proving the repository can compile and test on macOS without the dummy `APPLE` target. - -### Implementation Tasks - -- Add `src/macos/CMakeLists.txt`. -- Update `src/CMakeLists.txt` so `APPLE` adds `src/macos`. -- Link macOS platform target against: - - `CoreGraphics` - - `CoreFoundation` - - `IOKit` -- Add macOS headers and source stubs. -- Add macOS JSON serialization stubs for platform state. -- Add `tests/unit/macos/CMakeLists.txt`. -- Update `tests/unit/CMakeLists.txt` so `APPLE` adds macOS tests. -- Add mocks for `MacApiLayerInterface` and `MacDisplayDeviceInterface`. - -### Behavior - -- Read-only APIs can return empty values initially. -- Mutating APIs can return failure or unsupported behavior. -- The goal is build/test wiring, not feature completeness. - -### Acceptance Criteria - -- `cmake -G Ninja -B cmake-build-macos-tests -S .` configures on macOS. -- `cmake --build cmake-build-macos-tests` builds. -- `SKIP_SYSTEM_TESTS=1 ./cmake-build-macos-tests/tests/test_libdisplaydevice` runs the normal non-mutating suite. -- Doxygen succeeds with all new declarations documented. - -## Phase 1: Read-Only macOS Support - -### Objective - -Implement safe display enumeration and current-state queries without modifying system display settings. - -### Implementation Tasks - -- Implement active display enumeration. -- Implement online display enumeration. -- Implement IOKit display info lookup. -- Implement product/friendly-name extraction. -- Implement EDID extraction and parsing. -- Implement device ID generation. -- Implement current display mode conversion. -- Implement current topology read. -- Implement topology validation and comparison. -- Implement primary/main display query. -- Implement current HDR state query as unsupported. - -### API Coverage - -Supported: - -- `enumAvailableDevices` -- `getDisplayName` -- `getCurrentTopology` -- `isTopologyValid` -- `isTopologyTheSame` -- `getCurrentDisplayModes` -- `isPrimary` -- `getCurrentHdrStates` - -Not supported yet: - -- `setTopology` -- `setDisplayModes` -- `setAsPrimary` -- `setHdrStates` except no-op unsupported handling. -- `applySettings` for real mutations. - -### Testing - -Unit tests: - -- Active display enumeration success. -- Online-but-not-active handling if representable through mocks. -- Product name extraction. -- Missing product name fallback. -- EDID extraction and parse success. -- Missing EDID returns `std::nullopt`. -- Device ID generated from EDID. -- Device ID generated from vendor/product/serial fallback. -- Device ID generated from display ID fallback. -- Resolution uses pixel dimensions. -- Scale uses pixel-to-point ratio. -- Refresh rate conversion handles integer, fractional, and zero values. -- Topology groups mirrored displays. -- Topology comparison ignores group ordering where appropriate. -- HDR state returns `std::nullopt`. - -Live tests: - -- Read-only enumeration returns at least one active display. -- Main display is marked primary. -- Current mode can be read for each active display. - -Live tests should be non-mutating and safe to run by default. - -### Acceptance Criteria - -- A user can enumerate displays on macOS and receive meaningful IDs, names, EDID when available, current pixel resolution, scale, refresh, primary flag, and origin. -- No normal tests mutate display settings. -- Unsupported HDR is represented consistently. - -## Phase 2: Active-Display Mode Changes - -### Objective - -Support resolution and refresh changes on already-active displays, with reliable rollback and persisted revert. - -### Implementation Tasks - -- Implement mode matching by pixel resolution and refresh rate. -- Implement `setDisplayModes`. -- Implement mode guards. -- Implement `SettingsManager::applySettings` for active displays with: - - `VerifyOnly` - - `m_resolution` - - `m_refresh_rate` - - resolution and refresh together -- Implement `SettingsManager::revertSettings` for mode-only changes. -- Persist original display modes before mutation. - -### Mode Selection Rules - -- Filter to modes usable for desktop GUI. -- Prefer exact pixel resolution. -- Prefer exact refresh rate when requested. -- Use fuzzy refresh comparison for common fractional rates. -- If refresh is omitted, preserve current refresh where possible. -- If resolution is omitted, preserve current pixel resolution where possible. -- Re-read current mode after applying and verify it matches the requested mode within tolerance. - -### Transaction Rules - -- Do not persist until the display mode has been applied and verified. -- If persistence fails, roll back the mode change. -- If verification fails, roll back immediately and return failure. -- If rollback fails, log loudly but still return the original operation failure. - -### API Coverage - -Supported: - -- `applySettings` mode changes for active displays. -- `revertSettings` for mode-only persisted state. -- `setDisplayModes`. - -Still unsupported: - -- topology changes. -- primary/main display changes. -- true `EnsureActive`. -- `EnsureOnlyDisplay`. -- HDR writes. - -### Testing - -Unit tests: - -- Mode matching exact resolution and exact refresh. -- Mode matching exact resolution and fuzzy refresh. -- Mode matching with refresh omitted. -- Failure when no usable mode matches. -- Failure when display is inactive. -- Failure when CoreGraphics mode application fails. -- Failure when verification reads back the wrong mode. -- Rollback attempted on verification failure. -- Rollback attempted on persistence failure. -- Persistence skipped when mode already matches. - -Live tests: - -- Run as system tests; routine verification uses `SKIP_SYSTEM_TESTS=1` to skip them. -- Skip if only one mode is available. -- Change to another safe mode, verify, then revert. -- Never run display mutation tests by default in CI. - -### Acceptance Criteria - -- Users can change resolution and/or refresh on an active macOS display and revert through the existing public API. -- Unsupported fields fail before making unrelated display changes. -- Normal tests remain safe. - -## Phase 3: Topology, Main Display, And Mirroring - -### Objective - -Add supported display arrangement changes using CoreGraphics display configuration transactions. - -### Implementation Tasks - -- Implement `setTopology`. -- Implement topology guards. -- Implement `setAsPrimary`. -- Implement primary/main display guards. -- Extend `applySettings` for: - - `EnsurePrimary` - - best-effort `EnsureActive` - - mirroring groups -- Extend `revertSettings` for topology + primary + mode combinations. - -### Topology Rules - -- Each inner topology group represents a mirrored set. -- Each top-level group represents an extended display region. -- A single-device group means the display is extended/unmirrored. -- A multi-device group means secondary displays mirror the group's primary display. - -### Main Display Rules - -- Treat `CGDisplayIsMain` as the primary/main-display verification API. -- Move the target display to origin `(0, 0)` when making it primary. -- Reposition other displays relative to preserve a non-overlapping arrangement. -- Verify after applying. - -### `EnsureActive` Rules - -- If target is active, succeed. -- If target is online but mirrored/non-drawable, attempt to make it drawable. -- If target is disconnected, fail. - -### `EnsureOnlyDisplay` Rules - -- If the target is already the only active display, succeed. -- Otherwise fail with `DevicePrepFailed`. -- Do not silently replace this with mirroring. - -### Testing - -Unit tests: - -- Extended topology conversion. -- Mirrored topology conversion. -- Mixed topology conversion. -- Invalid topology rejection. -- CoreGraphics configuration begin failure. -- Origin configuration failure. -- Mirroring configuration failure. -- Complete configuration failure. -- Verification failure after reported success. -- Rollback after topology failure. -- Primary/main display success. -- Primary/main display verification failure. - -Live tests: - -- Opt-in only. -- Require at least two displays for topology tests. -- Save initial topology before each test. -- Always attempt revert in cleanup. -- Skip mirroring tests if hardware/software mirroring is unavailable or unstable. - -### Acceptance Criteria - -- Supported topology and main-display changes are verified after application. -- Revert restores the original topology/main/mode state for supported scenarios. -- Unsupported topology requests fail predictably. - -## Phase 4: Library V1A Hardening - -### Objective - -Polish the library-only macOS v1a contract after mode changes are implemented, without adding topology mutation. This phase should make the consumer boundary explicit enough that applications such as Sunshine can integrate the backend without relying on macOS-private behavior or legacy capture identifiers as durable configuration IDs. - -### Implementation Tasks - -- Define the macOS display identity contract: - - `m_device_id` is the stable best-effort library identity and the value consumers should store in configuration. - - `m_display_name` and `getDisplayName(device_id)` return the transient platform capture selector. - - On macOS v1a, that selector is the decimal `CGDirectDisplayID` string. -- Do not add compatibility aliases for old macOS numeric `output_name` values inside the library. Applications that already exposed raw CoreGraphics ids should migrate or pass through those old settings at their own boundary. -- Add tests for the macOS capture-token contract. -- Update README build instructions for macOS. -- Add macOS support matrix to documentation. -- Add Doxygen notes for unsupported HDR and `EnsureOnlyDisplay`. -- Move shared platform-neutral types/helpers into `common` if duplication has emerged. -- Add shared public contract tests for behavior that should pass on all platforms. -- Keep platform-specific tests for OS translation and platform edge cases. - -### Shared Contract Tests - -Possible shared tests: - -- Enumerated device IDs are non-empty and unique. -- Active devices have `m_info`. -- `getDisplayName` returns empty for unknown IDs. -- macOS `getDisplayName` returns the platform capture token for a known stable device id. -- `resetPersistence` succeeds with empty persistence. -- `revertSettings` succeeds when there is no cached state. -- Applying unsupported settings returns a failure result without persistence changes. - -### Documentation Topics - -- macOS v1 uses public APIs only. -- Resolution is pixel-based. -- Scale is derived from pixel-to-point ratio. -- Configuration should store `m_device_id`; `m_display_name`/`getDisplayName` is a platform capture token and may change across sessions. -- HDR state is unsupported. -- Disconnected remembered displays are not enumerated. -- `EnsureOnlyDisplay` is not equivalent to macOS mirroring and is unsupported unless already true. -- Display mutation tests are opt-in. - -## Phase 5: Display Power Management Surface - -### Objective - -Add a small cross-platform API for display wake and display-sleep prevention. This is separate from display settings: waking a sleeping display and preventing it from sleeping during capture is not the same thing as topology activation, primary display selection, or mode mutation. - -### Implementation Tasks - -- Add a new public RAII-style API for display capture preparation. -- Support a short wake-for-detection operation before consumers enumerate/select a capture target. -- Support a keep-awake guard whose destructor releases the platform assertion. -- On macOS, implement wake with `IOPMAssertionDeclareUserActivity` and keep-awake with `IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleDisplaySleep, ...)`. -- On Windows, implement wake and keep-awake with `SetThreadExecutionState`, replacing the equivalent Sunshine capture-backend behavior during integration. -- Keep this API independent from `SettingsManagerInterface::applySettings`; `DevicePreparation::EnsureActive` should continue to mean topology/display-configuration activation, not display power state. -- Add Doxygen documentation that clarifies failure behavior and lifetime ownership. - -### Testing - -- Unit-test guard lifetime and release behavior using platform API mocks. -- Unit-test wake retry/timeout behavior without requiring real display sleep. -- Keep real display-sleep tests manual or system-only; normal CI should not try to put a physical monitor to sleep. - -## Phase 6: Sunshine Consumer Integration - -### Objective - -Wire the accepted macOS library backend into Sunshine once the library work is ready to consume. This is intentionally separate from the library phases because it lives in a different repository/process and may depend on upstream PR acceptance. - -This phase should also move Sunshine's existing display wake / keep-awake behavior onto library-owned power-management APIs. Sunshine already handles this explicitly on Windows in its capture backend, so the integration should avoid leaving Windows on one path and macOS on another. - -### Implementation Tasks - -- Add a macOS branch to Sunshine's display-device settings-manager factory. -- Use stable library `device_id` values for new Sunshine display-device configuration. -- Keep Sunshine's existing macOS capture/input code consuming the numeric CoreGraphics display id returned by `map_output_name`. -- Handle existing Sunshine macOS configs that stored raw numeric CoreGraphics ids at the Sunshine boundary, either by migration to stable `device_id` or by a narrow pass-through fallback. -- Force, hide, or reject `dd_hdr_option=auto` on macOS until HDR mutation support exists, because the library intentionally fails requested HDR changes. -- Replace Sunshine's platform-local display wake / keep-awake code with the new library API on both macOS and Windows. -- Update Sunshine documentation and UI copy so macOS advertises `verify_only` plus resolution/refresh matching and revert, not topology preparation. - -## Suggested Release Milestones - -### macOS v1a - -Includes: - -- Phase 0. -- Phase 1. -- Phase 2. - -User-visible support: - -- Enumerate macOS displays. -- Query display names and current modes. -- Apply and revert resolution/refresh changes on active displays. -- Return macOS capture selectors through `getDisplayName` for consumers that need to bind capture to the configured display. -- Explicitly report unsupported HDR and unsupported topology preparation modes. - -This is the first practical release because it provides a useful workflow while avoiding the riskiest arrangement behavior. - -### macOS v1b - -Includes: - -- Phase 5, when the display power API is accepted. -- Phase 6, when the downstream Sunshine integration is ready. - -User-visible support: - -- Sunshine can use built-in macOS active-display resolution/refresh matching without an external `displayplacer` command. -- Existing Sunshine macOS raw-display-id configs are handled by Sunshine migration/fallback logic, not by expanding the library contract. -- Sunshine uses the library display-power API to wake and keep displays awake on both macOS and Windows. - -### macOS v2 - -Includes: - -- Phase 3, if topology work is still desired after v1a is proven. - -User-visible support: - -- Main display changes. -- Best-effort active-display preparation. -- Supported mirroring/topology changes. -- Revert across topology/main/mode combinations. - -## Resolved Design Defaults - -- Move shared types only after macOS needs them cleanly across multiple implemented features. -- Fail when `m_hdr_state` is supplied. -- Fail when no acceptable refresh match exists. -- Use `kCGConfigureForSession` for explicit apply/revert behavior, not permanent settings. -- Use stable best-effort `m_device_id` values for configuration. Use the platform capture selector for `m_display_name` and put human names in `m_friendly_name`. -- Keep live/system mutation tests in the normal test binary, but run routine verification with `SKIP_SYSTEM_TESTS=1`. -- Keep display power management separate from settings application; do not model display sleep as `DevicePreparation::EnsureActive`. - -## Risks - -### Display ID Stability - -Some virtual displays and adapters may not expose full EDID or serial metadata. The fallback path must be documented and tested. - -### HiDPI Mode Semantics - -macOS mode width/height can mean points or pixels depending on the API. The implementation must consistently use pixel APIs for `Resolution`. - -### Refresh Rate Semantics - -Variable refresh and zero refresh reporting can make exact matching impossible. The implementation should avoid over-promising refresh control. - -### Main Display Behavior - -Moving origin to `(0, 0)` may not always produce the expected main display on every macOS version or setup. Always verify. - -### Mirroring Behavior - -Hardware and software mirroring can change active/online display semantics. Tests must model and verify both where possible. - -### User Disruption - -Display mutation tests can interrupt real workflows. Keep them opt-in and aggressively revert. - -## Verification Commands - -On macOS: - -```bash -cmake -G Ninja -B cmake-build-macos-tests -S . -cmake --build cmake-build-macos-tests -SKIP_SYSTEM_TESTS=1 ./cmake-build-macos-tests/tests/test_libdisplaydevice -SKIP_SYSTEM_TESTS=1 ctest --test-dir cmake-build-macos-tests --output-on-failure -``` - -Documentation should also be built because the project treats missing Doxygen documentation as a build failure: - -```bash -cmake -S . -B cmake-build-macos-docs -G Ninja -DBUILD_DOCS=ON -DBUILD_TESTS=OFF -cmake --build cmake-build-macos-docs -``` From 45ed2f5ae50e8731a6856573c589df04172e96fd Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 20:11:07 -0400 Subject: [PATCH 12/17] fix cognitive complexity issues --- src/macos/mac_display_device_modes.cpp | 41 +++- src/macos/settings_manager_apply.cpp | 291 +++++++++++++++++-------- 2 files changed, 229 insertions(+), 103 deletions(-) diff --git a/src/macos/mac_display_device_modes.cpp b/src/macos/mac_display_device_modes.cpp index b1e2c79..f324c82 100644 --- a/src/macos/mac_display_device_modes.cpp +++ b/src/macos/mac_display_device_modes.cpp @@ -25,6 +25,34 @@ namespace display_device { 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 { @@ -79,7 +107,7 @@ namespace display_device { return false; } - if (!mac_utils::fuzzyCompareModes(*current_mode, mode) && !hasMatchingMode(m_m_api->getDisplayModes(*display_id), mode)) { + 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; } @@ -97,19 +125,14 @@ namespace display_device { 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 << "!"; - if (!changed_modes.empty()) { - static_cast(setDisplayModes(changed_modes)); - } + rollbackChangedModes(*this, changed_modes); return false; } - const auto verified_mode {m_m_api->getCurrentDisplayMode(display_id)}; - if (!verified_mode || !mac_utils::fuzzyCompareModes(*verified_mode, mode)) { + 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); - if (!changed_modes.empty()) { - static_cast(setDisplayModes(changed_modes)); - } + rollbackChangedModes(*this, changed_modes); return false; } diff --git a/src/macos/settings_manager_apply.cpp b/src/macos/settings_manager_apply.cpp index 1afb1d9..aa87d0d 100644 --- a/src/macos/settings_manager_apply.cpp +++ b/src/macos/settings_manager_apply.cpp @@ -8,6 +8,7 @@ // system includes #include #include +#include #include // local includes @@ -65,131 +66,233 @@ namespace display_device { return {std::next(std::begin(primary_devices)), std::end(primary_devices)}; } - } // namespace - MacSettingsManager::ApplyResult MacSettingsManager::applySettings(const SingleDisplayConfiguration &config) { - const auto api_access {m_dd_api->isApiAccessAvailable()}; - DD_LOG(info) << "Trying to apply macOS display device settings. API is available: " << toJson(api_access); + /** + * @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. + }; - if (!api_access) { - return ApplyResult::ApiTemporarilyUnavailable; + /** + * @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)}; } - DD_LOG(info) << "Using the following macOS configuration:\n" - << toJson(config); + /** + * @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( + 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; + } - if (config.m_hdr_state) { - return ApplyResult::HdrStatePrepFailed; - } + const auto devices {dd_api.enumAvailableDevices()}; + if (devices.empty()) { + DD_LOG(error) << "Failed to enumerate macOS display devices!"; + return std::nullopt; + } - if (config.m_device_prep != SingleDisplayConfiguration::DevicePreparation::VerifyOnly) { - DD_LOG(error) << "macOS phase 2 only supports VerifyOnly device preparation."; - return ApplyResult::DevicePrepFailed; - } + 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; + } - const auto topology_before_changes {m_dd_api->getCurrentTopology()}; - if (!m_dd_api->isTopologyValid(topology_before_changes)) { - DD_LOG(error) << "Retrieved current macOS topology is invalid:\n" - << toJson(topology_before_changes); - return ApplyResult::DevicePrepFailed; - } + 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 auto devices {m_dd_api->enumAvailableDevices()}; - if (devices.empty()) { - DD_LOG(error) << "Failed to enumerate macOS display devices!"; - return ApplyResult::DevicePrepFailed; - } + 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) + }; - const auto &cached_state {m_persistence_state->getState()}; - 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 ApplyResult::DevicePrepFailed; - } + 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; + } - 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 ApplyResult::DevicePrepFailed; + 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 + }; } - 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) - }; + /** + * @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; + } - 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 ApplyResult::DevicePrepFailed; + 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; } - new_state.m_modified.m_topology = topology_before_changes; + /** + * @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 + ) { + 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 MacSettingsManager::ApplyResult::DisplayModePrepFailed; + } - const auto cached_display_modes {cached_state ? cached_state->m_modified.m_original_modes : MacDeviceDisplayModeMap {}}; - const bool change_required {config.m_resolution || config.m_refresh_rate}; - const bool might_need_to_restore {!cached_display_modes.empty()}; + plan.m_state.m_modified.m_original_modes = original_display_modes; + return MacSettingsManager::ApplyResult::Ok; + } - bool rollback_modes_on_failure {false}; - MacDeviceDisplayModeMap modes_to_restore; + /** + * @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 + ) { + const bool change_required {config.m_resolution || config.m_refresh_rate}; + const bool might_need_to_restore {!plan.m_cached_display_modes.empty()}; + if (!change_required && !might_need_to_restore) { + return MacSettingsManager::ApplyResult::Ok; + } - if (change_required || might_need_to_restore) { - const auto topology_devices {mac_utils::flattenTopology(new_state.m_modified.m_topology)}; - const auto current_display_modes {m_dd_api->getCurrentDisplayModes(topology_devices)}; + 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 ApplyResult::DisplayModePrepFailed; + return MacSettingsManager::ApplyResult::DisplayModePrepFailed; } - const auto try_change = [this, &rollback_modes_on_failure, &modes_to_restore, ¤t_display_modes](const MacDeviceDisplayModeMap &new_modes) { - if (current_display_modes == new_modes) { - return true; - } + if (change_required) { + return applyRequestedModes(dd_api, config, current_display_modes, plan, rollback_state); + } - DD_LOG(info) << "Changing macOS display modes to:\n" - << toJson(new_modes); - if (!m_dd_api->setDisplayModes(new_modes)) { - return false; - } + 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 MacSettingsManager::ApplyResult::DisplayModePrepFailed; + } - const auto mode_keys_view {std::views::keys(new_modes)}; - const StringSet mode_keys {std::begin(mode_keys_view), std::end(mode_keys_view)}; - const auto verified_modes {m_dd_api->getCurrentDisplayModes(mode_keys)}; - if (verified_modes.empty()) { - DD_LOG(error) << "Failed to verify changed macOS display modes!"; - static_cast(m_dd_api->setDisplayModes(current_display_modes)); - return false; - } + return MacSettingsManager::ApplyResult::Ok; + } + } // namespace - if (current_display_modes != verified_modes) { - rollback_modes_on_failure = true; - modes_to_restore = current_display_modes; - } + MacSettingsManager::ApplyResult MacSettingsManager::applySettings(const SingleDisplayConfiguration &config) { + const auto api_access {m_dd_api->isApiAccessAvailable()}; + DD_LOG(info) << "Trying to apply macOS display device settings. API is available: " << toJson(api_access); - return true; - }; + if (!api_access) { + return ApplyResult::ApiTemporarilyUnavailable; + } - if (change_required) { - const auto original_display_modes {cached_display_modes.empty() ? current_display_modes : cached_display_modes}; - const auto new_display_modes { - mac_utils::computeNewDisplayModes(config.m_resolution, config.m_refresh_rate, configuring_primary_devices, device_to_configure, additional_devices_to_configure, original_display_modes) - }; - - if (!try_change(new_display_modes)) { - DD_LOG(error) << "Failed to apply new macOS display modes!"; - return ApplyResult::DisplayModePrepFailed; - } + DD_LOG(info) << "Using the following macOS configuration:\n" + << toJson(config); - new_state.m_modified.m_original_modes = original_display_modes; - } else if (!try_change(cached_display_modes)) { - DD_LOG(error) << "Failed to restore original macOS display modes!"; - return ApplyResult::DisplayModePrepFailed; - } + if (config.m_hdr_state) { + return ApplyResult::HdrStatePrepFailed; + } + + if (config.m_device_prep != SingleDisplayConfiguration::DevicePreparation::VerifyOnly) { + DD_LOG(error) << "macOS phase 2 only supports VerifyOnly device preparation."; + return ApplyResult::DevicePrepFailed; + } + + const auto &cached_state {m_persistence_state->getState()}; + auto apply_plan {createApplyPlan(*m_dd_api, config, cached_state)}; + if (!apply_plan) { + return ApplyResult::DevicePrepFailed; + } + + ModeRollbackState mode_rollback; + if (const auto mode_result {applyDisplayModes(*m_dd_api, config, *apply_plan, mode_rollback)}; mode_result != ApplyResult::Ok) { + return mode_result; } - if (!m_persistence_state->persistState(new_state)) { + if (!m_persistence_state->persistState(apply_plan->m_state)) { DD_LOG(error) << "Failed to persist macOS display settings! Undoing changes..."; - if (rollback_modes_on_failure) { - static_cast(m_dd_api->setDisplayModes(modes_to_restore)); + if (mode_rollback.m_required) { + static_cast(m_dd_api->setDisplayModes(mode_rollback.m_modes)); } return ApplyResult::PersistenceSaveFailed; } From 9a0468df11f68c3ae53ec8deec5b88a1b8352651 Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 20:19:28 -0400 Subject: [PATCH 13/17] general sonar sweep --- .../display_device/macos/mac_display_device.h | 3 ++- .../display_device/macos/settings_manager.h | 2 +- src/macos/mac_api_layer.cpp | 6 ++---- src/macos/mac_display_device_general.cpp | 4 ++-- src/macos/mac_display_device_hdr.cpp | 14 ++++++-------- src/macos/mac_display_device_topology.cpp | 5 +++-- src/windows/win_api_layer.cpp | 9 +++------ 7 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/macos/include/display_device/macos/mac_display_device.h b/src/macos/include/display_device/macos/mac_display_device.h index e9aeaa8..3089965 100644 --- a/src/macos/include/display_device/macos/mac_display_device.h +++ b/src/macos/include/display_device/macos/mac_display_device.h @@ -6,6 +6,7 @@ // system includes #include +#include // local includes #include "mac_api_layer_interface.h" @@ -95,7 +96,7 @@ namespace display_device { * @param query_type Display list type to search. * @return Display id, or empty optional if not found. */ - [[nodiscard]] std::optional getDisplayId(const std::string &device_id, MacQueryType query_type) const; + [[nodiscard]] std::optional getDisplayId(std::string_view device_id, MacQueryType query_type) const; std::shared_ptr m_m_api; }; diff --git a/src/macos/include/display_device/macos/settings_manager.h b/src/macos/include/display_device/macos/settings_manager.h index d1d09ab..abdfcc8 100644 --- a/src/macos/include/display_device/macos/settings_manager.h +++ b/src/macos/include/display_device/macos/settings_manager.h @@ -73,6 +73,6 @@ namespace display_device { std::shared_ptr m_dd_api; std::shared_ptr m_audio_context_api; std::unique_ptr m_persistence_state; - MacWorkarounds m_workarounds; + [[no_unique_address]] MacWorkarounds m_workarounds; }; } // namespace display_device diff --git a/src/macos/mac_api_layer.cpp b/src/macos/mac_api_layer.cpp index 02e75cd..925b826 100644 --- a/src/macos/mac_api_layer.cpp +++ b/src/macos/mac_api_layer.cpp @@ -11,11 +11,11 @@ #include #include #include +#include #include #include #include #include -#include #include #include #include @@ -458,9 +458,7 @@ namespace display_device { * @return Formatted device id. */ [[nodiscard]] std::string makeDeviceId(const std::string_view prefix, const std::uint64_t hash) { - std::ostringstream output; - output << "macos-" << prefix << '-' << std::hex << std::setw(16) << std::setfill('0') << hash; - return output.str(); + return std::format("macos-{}-{:016x}", prefix, hash); } } // namespace diff --git a/src/macos/mac_display_device_general.cpp b/src/macos/mac_display_device_general.cpp index a10e110..03d27dc 100644 --- a/src/macos/mac_display_device_general.cpp +++ b/src/macos/mac_display_device_general.cpp @@ -57,7 +57,7 @@ namespace display_device { } } - devices.push_back({device_id, display_name, friendly_name, edid, info}); + devices.emplace_back(device_id, display_name, friendly_name, edid, info); } return devices; @@ -72,7 +72,7 @@ namespace display_device { return m_m_api->getDisplayName(*display_id); } - std::optional MacDisplayDevice::getDisplayId(const std::string &device_id, const MacQueryType query_type) const { + std::optional MacDisplayDevice::getDisplayId(const std::string_view device_id, const MacQueryType query_type) const { if (device_id.empty()) { return std::nullopt; } diff --git a/src/macos/mac_display_device_hdr.cpp b/src/macos/mac_display_device_hdr.cpp index 50470be..e64cf10 100644 --- a/src/macos/mac_display_device_hdr.cpp +++ b/src/macos/mac_display_device_hdr.cpp @@ -5,6 +5,9 @@ // 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; @@ -16,13 +19,8 @@ namespace display_device { } bool MacDisplayDevice::setHdrStates(const MacHdrStateMap &states) { - for (const auto &[device_id, state] : states) { - static_cast(device_id); - if (state) { - return false; - } - } - - return true; + return std::ranges::all_of(states, [](const auto &entry) { + return !entry.second; + }); } } // namespace display_device diff --git a/src/macos/mac_display_device_topology.cpp b/src/macos/mac_display_device_topology.cpp index e4cd574..87d567c 100644 --- a/src/macos/mac_display_device_topology.cpp +++ b/src/macos/mac_display_device_topology.cpp @@ -33,8 +33,9 @@ namespace display_device { MacActiveTopology topology; topology.reserve(groups.size()); - for (auto &group : groups) { - topology.push_back(std::move(group.second)); + for (auto &[group_key, device_ids] : groups) { + static_cast(group_key); + topology.push_back(std::move(device_ids)); } return topology; diff --git a/src/windows/win_api_layer.cpp b/src/windows/win_api_layer.cpp index fc32a29..f482e79 100644 --- a/src/windows/win_api_layer.cpp +++ b/src/windows/win_api_layer.cpp @@ -530,8 +530,7 @@ namespace display_device { } bool WinApiLayer::wakeDisplay(const std::chrono::milliseconds timeout) { - const auto result {SetThreadExecutionState(ES_DISPLAY_REQUIRED)}; - if (result == 0) { + if (const auto result {SetThreadExecutionState(ES_DISPLAY_REQUIRED)}; result == 0) { DD_LOG(error) << getErrorString(static_cast(GetLastError())) << " failed to wake display."; return false; } @@ -545,8 +544,7 @@ namespace display_device { } bool WinApiLayer::keepDisplayAwake() { - const auto result {SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED)}; - if (result == 0) { + 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; } @@ -555,8 +553,7 @@ namespace display_device { } bool WinApiLayer::restorePowerRequest() { - const auto result {SetThreadExecutionState(ES_CONTINUOUS)}; - if (result == 0) { + if (const auto result {SetThreadExecutionState(ES_CONTINUOUS)}; result == 0) { DD_LOG(error) << getErrorString(static_cast(GetLastError())) << " failed to restore display power request."; return false; } From dff5949cc1f5cb17a9099659d2616afd2e77f872 Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 20:37:26 -0400 Subject: [PATCH 14/17] decrease code duplication in tests --- tests/unit/macos/test_mac_display_device.cpp | 167 ++++----- tests/unit/macos/test_settings_manager.cpp | 348 +++++++------------ 2 files changed, 194 insertions(+), 321 deletions(-) diff --git a/tests/unit/macos/test_mac_display_device.cpp b/tests/unit/macos/test_mac_display_device.cpp index c97d8a1..aa9a335 100644 --- a/tests/unit/macos/test_mac_display_device.cpp +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -16,11 +16,53 @@ namespace { 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}}; + // Test fixture(s) for this file class MacDisplayDeviceMocked: public BaseTest { public: + void expectActiveDeviceLookup(Sequence &sequence, const std::string &device_id = "DeviceId1") { + 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(Sequence &sequence, const display_device::MacDisplayMode &mode) { + EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(mode)); + } + + void expectAvailableModes(Sequence &sequence, const display_device::MacDisplayModeList &modes) { + EXPECT_CALL(*m_layer, getDisplayModes(1)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(modes)); + } + + void expectSetMode(Sequence &sequence, const display_device::MacDisplayMode &mode, const bool result) { + EXPECT_CALL(*m_layer, setDisplayMode(1, mode)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(result)); + } + + void expectModePreparation(Sequence &sequence, const display_device::MacDisplayMode ¤t_mode = CURRENT_MODE, display_device::MacDisplayModeList available_modes = {REQUESTED_MODE}) { + 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}; }; @@ -269,133 +311,54 @@ TEST_F_S(SetDisplayModes, EmptyModes) { } TEST_F_S(SetDisplayModes, InactiveDisplay) { - 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("DeviceId2")); + Sequence sequence; + expectActiveDeviceLookup(sequence, "DeviceId2"); EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1920, 1080}, {60, 1}}}})); } TEST_F_S(SetDisplayModes, UnavailableMode) { - 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, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); - EXPECT_CALL(*m_layer, getDisplayModes(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}})); + Sequence sequence; + expectModePreparation(sequence); EXPECT_FALSE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1024, 768}, {60, 1}}}})); } TEST_F_S(SetDisplayModes, AlreadyCurrent) { - 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, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + Sequence sequence; + expectActiveDeviceLookup(sequence); + expectCurrentMode(sequence, CURRENT_MODE); EXPECT_TRUE(m_mac_dd.setDisplayModes({{"DeviceId1", {{1920, 1080}, {5985, 100}}}})); } TEST_F_S(SetDisplayModes, Success) { - InSequence sequence; - - 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, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); - EXPECT_CALL(*m_layer, getDisplayModes(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}})); - EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1280, 720}, {60, 1}})) - .Times(1) - .WillOnce(Return(true)); - EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1280, 720}, {60, 1}})); + 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) { - InSequence sequence; - - 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, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); - EXPECT_CALL(*m_layer, getDisplayModes(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}})); - EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1280, 720}, {60, 1}})) - .Times(1) - .WillOnce(Return(false)); + 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) { - InSequence sequence; - - 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, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); - EXPECT_CALL(*m_layer, getDisplayModes(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayModeList {{{1280, 720}, {60, 1}}, {{1920, 1080}, {60, 1}}})); - EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1280, 720}, {60, 1}})) - .Times(1) - .WillOnce(Return(true)); - EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1024, 768}, {60, 1}})); - 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, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1024, 768}, {60, 1}})); - EXPECT_CALL(*m_layer, getDisplayModes(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayModeList {{{1920, 1080}, {60, 1}}})); - EXPECT_CALL(*m_layer, setDisplayMode(1, display_device::MacDisplayMode {{1920, 1080}, {60, 1}})) - .Times(1) - .WillOnce(Return(true)); - EXPECT_CALL(*m_layer, getCurrentDisplayMode(1)) - .Times(1) - .WillOnce(Return(display_device::MacDisplayMode {{1920, 1080}, {60, 1}})); + 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}}}})); } diff --git a/tests/unit/macos/test_settings_manager.cpp b/tests/unit/macos/test_settings_manager.cpp index 8d808ec..91a99d2 100644 --- a/tests/unit/macos/test_settings_manager.cpp +++ b/tests/unit/macos/test_settings_manager.cpp @@ -15,6 +15,7 @@ namespace { using ::testing::HasSubstr; using ::testing::InSequence; using ::testing::Return; + using ::testing::Sequence; using ::testing::StrictMock; const display_device::MacActiveTopology DEFAULT_TOPOLOGY { @@ -34,6 +35,10 @@ namespace { {"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) { @@ -78,16 +83,7 @@ namespace { } display_device::MacSingleDisplayConfigState makeAppliedModeState() { - return { - {DEFAULT_TOPOLOGY, - {"DeviceId1"}}, - {display_device::MacSingleDisplayConfigState::Modified { - DEFAULT_TOPOLOGY, - DEFAULT_MODES, - {}, - {}, - }} - }; + return makeModeState(); } // Test fixture(s) for this file @@ -106,6 +102,80 @@ namespace { return *m_impl; } + void expectNoStateLoad() { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeNoState())); + } + + void expectStoredStateLoad(const display_device::MacSingleDisplayConfigState &state) { + EXPECT_CALL(*m_settings_persistence_api, load()) + .Times(1) + .WillOnce(Return(serializeState(state))); + } + + void expectApiAvailable(Sequence &sequence) { + EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(true)); + } + + void expectApplyPreparation(Sequence &sequence) { + 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(Sequence &sequence, const display_device::MacDeviceDisplayModeMap &modes) { + EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(modes)); + } + + void expectSetModes(Sequence &sequence, const display_device::MacDeviceDisplayModeMap &modes, const bool result) { + EXPECT_CALL(*m_dd_api, setDisplayModes(modes)) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(result)); + } + + void expectStoreState(Sequence &sequence, const display_device::MacSingleDisplayConfigState &state, const bool result) { + EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(state))) + .Times(1) + .InSequence(sequence) + .WillOnce(Return(result)); + } + + void expectModeRevertPreparation(Sequence &sequence, const display_device::MacDeviceDisplayModeMap ¤t_modes) { + 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>()}; @@ -143,9 +213,7 @@ TEST_F_S(EnumAvailableDevices) { std::nullopt} }; - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); + expectNoStateLoad(); EXPECT_CALL(*m_dd_api, enumAvailableDevices()) .Times(1) .WillOnce(Return(test_list)); @@ -154,9 +222,7 @@ TEST_F_S(EnumAvailableDevices) { } TEST_F_S(GetDisplayName) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); + expectNoStateLoad(); EXPECT_CALL(*m_dd_api, getDisplayName("DeviceId1")) .Times(1) .WillOnce(Return("DisplayName1")); @@ -165,17 +231,13 @@ TEST_F_S(GetDisplayName) { } TEST_F_S(ResetPersistence, NoPersistence) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); + expectNoStateLoad(); EXPECT_TRUE(getImpl().resetPersistence()); } TEST_F_S(ResetPersistence, FailedToReset) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeState(makeState()))); + expectStoredStateLoad(makeState()); EXPECT_CALL(*m_settings_persistence_api, clear()) .Times(1) .WillOnce(Return(false)); @@ -184,9 +246,7 @@ TEST_F_S(ResetPersistence, FailedToReset) { } TEST_F_S(ResetPersistence, PersistenceReset, NoCapturedDevice) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeState(makeState()))); + expectStoredStateLoad(makeState()); EXPECT_CALL(*m_settings_persistence_api, clear()) .Times(1) .WillOnce(Return(true)); @@ -198,9 +258,7 @@ TEST_F_S(ResetPersistence, PersistenceReset, NoCapturedDevice) { } TEST_F_S(ResetPersistence, PersistenceReset, WithCapturedDevice) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeState(makeState()))); + expectStoredStateLoad(makeState()); EXPECT_CALL(*m_settings_persistence_api, clear()) .Times(1) .WillOnce(Return(true)); @@ -214,9 +272,7 @@ TEST_F_S(ResetPersistence, PersistenceReset, WithCapturedDevice) { } TEST_F_S(ApplySettings, ApiTemporarilyUnavailable) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); + expectNoStateLoad(); EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) .Times(1) .WillOnce(Return(false)); @@ -225,9 +281,7 @@ TEST_F_S(ApplySettings, ApiTemporarilyUnavailable) { } TEST_F_S(ApplySettings, HdrUnsupported) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); + expectNoStateLoad(); EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) .Times(1) .WillOnce(Return(true)); @@ -241,34 +295,13 @@ TEST_F_S(ApplySettings, HdrUnsupported) { TEST_F_S(ApplySettings, DisplayModeSuccess) { const auto expected_state {makeAppliedModeState()}; - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); - 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, enumAvailableDevices()) - .Times(1) - .WillOnce(Return(DEFAULT_DEVICES)); - EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) - .Times(1) - .WillOnce(Return(DEFAULT_MODES)); - EXPECT_CALL(*m_dd_api, setDisplayModes(CHANGED_MODES)) - .Times(1) - .WillOnce(Return(true)); - EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) - .Times(1) - .WillOnce(Return(CHANGED_MODES)); - EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(expected_state))) - .Times(1) - .WillOnce(Return(true)); + 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}}), @@ -277,9 +310,7 @@ TEST_F_S(ApplySettings, DisplayModeSuccess) { } TEST_F_S(ApplySettings, DevicePrepUnsupported) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); + expectNoStateLoad(); EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) .Times(1) .WillOnce(Return(true)); @@ -302,46 +333,18 @@ TEST_F_S(ApplySettings, VerifyOnlyNoChanges) { }} }; - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); - 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, enumAvailableDevices()) - .Times(1) - .WillOnce(Return(DEFAULT_DEVICES)); - EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(expected_state))) - .Times(1) - .WillOnce(Return(true)); + expectNoStateLoad(); + Sequence sequence; + expectApplyPreparation(sequence); + expectStoreState(sequence, expected_state, true); EXPECT_EQ(getImpl().applySettings({}), display_device::MacSettingsManager::ApplyResult::Ok); } TEST_F_S(ApplySettings, InactiveDevice) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); - 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, enumAvailableDevices()) - .Times(1) - .WillOnce(Return(DEFAULT_DEVICES)); + expectNoStateLoad(); + Sequence sequence; + expectApplyPreparation(sequence); EXPECT_EQ( getImpl().applySettings({.m_device_id = "DeviceId3"}), @@ -350,28 +353,11 @@ TEST_F_S(ApplySettings, InactiveDevice) { } TEST_F_S(ApplySettings, DisplayModeApplyFailed) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); - 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, enumAvailableDevices()) - .Times(1) - .WillOnce(Return(DEFAULT_DEVICES)); - EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) - .Times(1) - .WillOnce(Return(DEFAULT_MODES)); - EXPECT_CALL(*m_dd_api, setDisplayModes(CHANGED_MODES)) - .Times(1) - .WillOnce(Return(false)); + 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}}), @@ -382,37 +368,14 @@ TEST_F_S(ApplySettings, DisplayModeApplyFailed) { TEST_F_S(ApplySettings, PersistenceFailureRollsBackModes) { const auto expected_state {makeAppliedModeState()}; - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); - 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, enumAvailableDevices()) - .Times(1) - .WillOnce(Return(DEFAULT_DEVICES)); - EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) - .Times(1) - .WillOnce(Return(DEFAULT_MODES)); - EXPECT_CALL(*m_dd_api, setDisplayModes(CHANGED_MODES)) - .Times(1) - .WillOnce(Return(true)); - EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) - .Times(1) - .WillOnce(Return(CHANGED_MODES)); - EXPECT_CALL(*m_settings_persistence_api, store(*serializeState(expected_state))) - .Times(1) - .WillOnce(Return(false)); - EXPECT_CALL(*m_dd_api, setDisplayModes(DEFAULT_MODES)) - .Times(1) - .WillOnce(Return(true)); + 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}}), @@ -421,17 +384,13 @@ TEST_F_S(ApplySettings, PersistenceFailureRollsBackModes) { } TEST_F_S(RevertSettings, NoPersistence) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeNoState())); + expectNoStateLoad(); EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::Ok); } TEST_F_S(RevertSettings, ApiTemporarilyUnavailable) { - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeState(makeState()))); + expectStoredStateLoad(makeState()); EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) .Times(1) .WillOnce(Return(false)); @@ -440,77 +399,30 @@ TEST_F_S(RevertSettings, ApiTemporarilyUnavailable) { } TEST_F_S(RevertSettings, ModeOnly) { - const display_device::MacDeviceDisplayModeMap current_modes { - {"DeviceId1", {{1280, 720}, {60, 1}}}, - {"DeviceId2", {{2560, 1440}, {120, 1}}}, - }; - - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeState(makeModeState()))); - 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(2) - .WillRepeatedly(Return(true)); - EXPECT_CALL(*m_dd_api, isTopologyTheSame(DEFAULT_TOPOLOGY, DEFAULT_TOPOLOGY)) - .Times(1) - .WillOnce(Return(true)); - EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) - .Times(1) - .WillOnce(Return(current_modes)); - EXPECT_CALL(*m_dd_api, setDisplayModes(DEFAULT_MODES)) - .Times(1) - .WillOnce(Return(true)); + 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) { - const display_device::MacDeviceDisplayModeMap current_modes { - {"DeviceId1", {{1280, 720}, {60, 1}}}, - {"DeviceId2", {{2560, 1440}, {120, 1}}}, - }; - - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeState(makeModeState()))); - 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(2) - .WillRepeatedly(Return(true)); - EXPECT_CALL(*m_dd_api, isTopologyTheSame(DEFAULT_TOPOLOGY, DEFAULT_TOPOLOGY)) - .Times(1) - .WillOnce(Return(true)); - EXPECT_CALL(*m_dd_api, getCurrentDisplayModes(display_device::StringSet {"DeviceId1", "DeviceId2"})) - .Times(1) - .WillOnce(Return(current_modes)); - EXPECT_CALL(*m_dd_api, setDisplayModes(DEFAULT_MODES)) - .Times(1) - .WillOnce(Return(true)); + expectStoredStateLoad(makeModeState()); + Sequence sequence; + expectModeRevertPreparation(sequence, CURRENT_REVERT_MODES); EXPECT_CALL(*m_settings_persistence_api, clear()) .Times(1) + .InSequence(sequence) .WillOnce(Return(false)); - EXPECT_CALL(*m_dd_api, setDisplayModes(current_modes)) - .Times(1) - .WillOnce(Return(true)); + expectSetModes(sequence, CURRENT_REVERT_MODES, true); EXPECT_EQ(getImpl().revertSettings(), display_device::MacSettingsManager::RevertResult::PersistenceSaveFailed); } @@ -519,9 +431,7 @@ TEST_F_S(RevertSettings, TopologyUnsupported) { auto state {makeModeState()}; state.m_modified.m_topology = {{"DeviceId2"}}; - EXPECT_CALL(*m_settings_persistence_api, load()) - .Times(1) - .WillOnce(Return(serializeState(state))); + expectStoredStateLoad(state); InSequence sequence; EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) .Times(1) From 237047f186f524b61fdfe536b8388859cb3aa5ab Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 20:55:23 -0400 Subject: [PATCH 15/17] const-correct createApplyPlan; other minor sonar nits --- src/macos/settings_manager_apply.cpp | 37 +++++++++++--------- tests/unit/macos/test_mac_display_device.cpp | 11 +++--- tests/unit/macos/test_settings_manager.cpp | 16 ++++----- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/macos/settings_manager_apply.cpp b/src/macos/settings_manager_apply.cpp index aa87d0d..4cb6fa8 100644 --- a/src/macos/settings_manager_apply.cpp +++ b/src/macos/settings_manager_apply.cpp @@ -104,7 +104,7 @@ namespace display_device { * @return Prepared apply data, or empty optional on failure. */ [[nodiscard]] std::optional createApplyPlan( - MacDisplayDeviceInterface &dd_api, + const MacDisplayDeviceInterface &dd_api, const SingleDisplayConfiguration &config, const std::optional &cached_state ) { @@ -208,14 +208,16 @@ namespace display_device { 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 MacSettingsManager::ApplyResult::DisplayModePrepFailed; + return DisplayModePrepFailed; } plan.m_state.m_modified.m_original_modes = original_display_modes; - return MacSettingsManager::ApplyResult::Ok; + return Ok; } /** @@ -232,17 +234,18 @@ namespace display_device { MacApplyPlan &plan, ModeRollbackState &rollback_state ) { + using enum SettingsManagerInterface::ApplyResult; + const bool change_required {config.m_resolution || config.m_refresh_rate}; - const bool might_need_to_restore {!plan.m_cached_display_modes.empty()}; - if (!change_required && !might_need_to_restore) { - return MacSettingsManager::ApplyResult::Ok; + 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 MacSettingsManager::ApplyResult::DisplayModePrepFailed; + return DisplayModePrepFailed; } if (change_required) { @@ -251,41 +254,43 @@ namespace display_device { 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 MacSettingsManager::ApplyResult::DisplayModePrepFailed; + return DisplayModePrepFailed; } - return MacSettingsManager::ApplyResult::Ok; + 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 ApplyResult::ApiTemporarilyUnavailable; + return ApiTemporarilyUnavailable; } DD_LOG(info) << "Using the following macOS configuration:\n" << toJson(config); if (config.m_hdr_state) { - return ApplyResult::HdrStatePrepFailed; + return HdrStatePrepFailed; } if (config.m_device_prep != SingleDisplayConfiguration::DevicePreparation::VerifyOnly) { DD_LOG(error) << "macOS phase 2 only supports VerifyOnly device preparation."; - return ApplyResult::DevicePrepFailed; + return DevicePrepFailed; } const auto &cached_state {m_persistence_state->getState()}; auto apply_plan {createApplyPlan(*m_dd_api, config, cached_state)}; if (!apply_plan) { - return ApplyResult::DevicePrepFailed; + return DevicePrepFailed; } ModeRollbackState mode_rollback; - if (const auto mode_result {applyDisplayModes(*m_dd_api, config, *apply_plan, mode_rollback)}; mode_result != ApplyResult::Ok) { + if (const auto mode_result {applyDisplayModes(*m_dd_api, config, *apply_plan, mode_rollback)}; mode_result != Ok) { return mode_result; } @@ -294,9 +299,9 @@ namespace display_device { if (mode_rollback.m_required) { static_cast(m_dd_api->setDisplayModes(mode_rollback.m_modes)); } - return ApplyResult::PersistenceSaveFailed; + return PersistenceSaveFailed; } - return ApplyResult::Ok; + return Ok; } } // namespace display_device diff --git a/tests/unit/macos/test_mac_display_device.cpp b/tests/unit/macos/test_mac_display_device.cpp index aa9a335..0d9e31a 100644 --- a/tests/unit/macos/test_mac_display_device.cpp +++ b/tests/unit/macos/test_mac_display_device.cpp @@ -21,11 +21,12 @@ namespace { 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(Sequence &sequence, const std::string &device_id = "DeviceId1") { + 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) @@ -36,28 +37,28 @@ namespace { .WillOnce(Return(device_id)); } - void expectCurrentMode(Sequence &sequence, const display_device::MacDisplayMode &mode) { + 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(Sequence &sequence, const display_device::MacDisplayModeList &modes) { + 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(Sequence &sequence, const display_device::MacDisplayMode &mode, const bool result) { + 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(Sequence &sequence, const display_device::MacDisplayMode ¤t_mode = CURRENT_MODE, display_device::MacDisplayModeList available_modes = {REQUESTED_MODE}) { + 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); diff --git a/tests/unit/macos/test_settings_manager.cpp b/tests/unit/macos/test_settings_manager.cpp index 91a99d2..b421969 100644 --- a/tests/unit/macos/test_settings_manager.cpp +++ b/tests/unit/macos/test_settings_manager.cpp @@ -102,26 +102,26 @@ namespace { return *m_impl; } - void expectNoStateLoad() { + void expectNoStateLoad() const { EXPECT_CALL(*m_settings_persistence_api, load()) .Times(1) .WillOnce(Return(serializeNoState())); } - void expectStoredStateLoad(const display_device::MacSingleDisplayConfigState &state) { + void expectStoredStateLoad(const display_device::MacSingleDisplayConfigState &state) const { EXPECT_CALL(*m_settings_persistence_api, load()) .Times(1) .WillOnce(Return(serializeState(state))); } - void expectApiAvailable(Sequence &sequence) { + void expectApiAvailable(const Sequence &sequence) const { EXPECT_CALL(*m_dd_api, isApiAccessAvailable()) .Times(1) .InSequence(sequence) .WillOnce(Return(true)); } - void expectApplyPreparation(Sequence &sequence) { + void expectApplyPreparation(const Sequence &sequence) const { expectApiAvailable(sequence); EXPECT_CALL(*m_dd_api, getCurrentTopology()) .Times(1) @@ -137,28 +137,28 @@ namespace { .WillOnce(Return(DEFAULT_DEVICES)); } - void expectCurrentModes(Sequence &sequence, const display_device::MacDeviceDisplayModeMap &modes) { + 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(Sequence &sequence, const display_device::MacDeviceDisplayModeMap &modes, const bool result) { + 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(Sequence &sequence, const display_device::MacSingleDisplayConfigState &state, const bool 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(Sequence &sequence, const display_device::MacDeviceDisplayModeMap ¤t_modes) { + void expectModeRevertPreparation(const Sequence &sequence, const display_device::MacDeviceDisplayModeMap ¤t_modes) const { expectApiAvailable(sequence); EXPECT_CALL(*m_dd_api, getCurrentTopology()) .Times(1) From f32c659c46d2b5e34b7a4990409bd23fb1392898 Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 21:24:56 -0400 Subject: [PATCH 16/17] address sonar code duplication --- .../detail/persistent_state_utils.h | 66 +++++++++++ .../detail/settings_state_utils.h | 108 ++++++++++++++++++ src/macos/persistent_state.cpp | 37 ++---- src/macos/settings_utils.cpp | 78 +++---------- src/windows/persistent_state.cpp | 37 ++---- src/windows/settings_utils.cpp | 82 +++---------- 6 files changed, 221 insertions(+), 187 deletions(-) create mode 100644 src/common/include/display_device/detail/persistent_state_utils.h create mode 100644 src/common/include/display_device/detail/settings_state_utils.h 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/macos/persistent_state.cpp b/src/macos/persistent_state.cpp index 0beadea..2683627 100644 --- a/src/macos/persistent_state.cpp +++ b/src/macos/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/macos/json.h" #include "display_device/noop_settings_persistence.h" @@ -53,33 +54,15 @@ namespace display_device { } bool MacPersistentState::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 macOS 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 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 { diff --git a/src/macos/settings_utils.cpp b/src/macos/settings_utils.cpp index 7b6278b..9179f2c 100644 --- a/src/macos/settings_utils.cpp +++ b/src/macos/settings_utils.cpp @@ -14,6 +14,7 @@ #include // local includes +#include "display_device/detail/settings_state_utils.h" #include "display_device/logging.h" #include "display_device/macos/json.h" @@ -55,46 +56,6 @@ namespace display_device::mac_utils { return device_ids; } - /** - * @brief Remove unavailable devices from a topology. - * @param topology Topology to strip. - * @param devices Currently available devices. - * @return Topology containing only available devices. - */ - [[nodiscard]] MacActiveTopology stripTopology(const MacActiveTopology &topology, const EnumeratedDeviceList &devices) { - const StringSet available_device_ids {getDeviceIds(devices, anyDevice)}; - - MacActiveTopology 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 unavailable device ids. - * @param device_ids Device ids to strip. - * @param devices Currently available devices. - * @return Device ids containing only available devices. - */ - [[nodiscard]] StringSet stripDevices(const StringSet &device_ids, const EnumeratedDeviceList &devices) { - const 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 Merge the primary and additional devices into one ordered list. * @param device_to_configure Primary device to configure. @@ -169,32 +130,19 @@ namespace display_device::mac_utils { const MacSingleDisplayConfigState::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 macOS device list does not contain any device from the initial state!"; - return std::nullopt; - } - - if (initial_primary_devices.empty()) { - initial_primary_devices = getDeviceIds(devices, primaryOnlyDevices); - if (initial_primary_devices.empty()) { - DD_LOG(error) << "Enumerated macOS device list does not contain primary devices!"; - return std::nullopt; + 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); } - } - - if (initial_state.m_topology != stripped_initial_topology || initial_state.m_primary_devices != initial_primary_devices) { - DD_LOG(warning) << "Adapting macOS initial state because some devices are unavailable.\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 MacSingleDisplayConfigState::Initial { - stripped_initial_topology, - initial_primary_devices - }; + ); } MacDeviceDisplayModeMap computeNewDisplayModes( 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) { From 9274aba55e7ba91f51cb7708c9e936d5d144641f Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 21 Jun 2026 22:43:36 -0400 Subject: [PATCH 17/17] add NOSONAR directives to suppress classes/structs having too many methods/fields --- tests/unit/macos/utils/mock_mac_api_layer.h | 2 +- tests/unit/macos/utils/mock_mac_display_device.h | 2 +- tests/unit/windows/utils/mock_win_api_layer.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/macos/utils/mock_mac_api_layer.h b/tests/unit/macos/utils/mock_mac_api_layer.h index df9da95..37f86d8 100644 --- a/tests/unit/macos/utils/mock_mac_api_layer.h +++ b/tests/unit/macos/utils/mock_mac_api_layer.h @@ -7,7 +7,7 @@ #include "display_device/macos/mac_api_layer_interface.h" namespace display_device { - class MockMacApiLayer: public MacApiLayerInterface { + 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)); diff --git a/tests/unit/macos/utils/mock_mac_display_device.h b/tests/unit/macos/utils/mock_mac_display_device.h index 23c7eb7..12fff19 100644 --- a/tests/unit/macos/utils/mock_mac_display_device.h +++ b/tests/unit/macos/utils/mock_mac_display_device.h @@ -7,7 +7,7 @@ #include "display_device/macos/mac_display_device_interface.h" namespace display_device { - class MockMacDisplayDevice: public MacDisplayDeviceInterface { + 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)); diff --git a/tests/unit/windows/utils/mock_win_api_layer.h b/tests/unit/windows/utils/mock_win_api_layer.h index b3a3db2..3bfb6e1 100644 --- a/tests/unit/windows/utils/mock_win_api_layer.h +++ b/tests/unit/windows/utils/mock_win_api_layer.h @@ -7,7 +7,7 @@ #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));