diff --git a/.gitignore b/.gitignore index 15c1155..dfa28df 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ /example/cmake-build-debug -/build +/build* /.opencode /AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt index d793021..ac7f434 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,12 +62,17 @@ set(ROBOT_COMMON_SOURCES src/common/Image.cpp src/common/Recorder.cpp src/common/EventTap.cpp + src/common/Windows.cpp ) # ── Platform selection: exactly one directory is compiled ───────────────────── # This is the single compile boundary. No consumer translation unit contains a # platform #ifdef; the platform is chosen here, once. if(APPLE) + # The window backend talks to ScreenCaptureKit, which has no C API. + enable_language(OBJCXX) + set(CMAKE_OBJCXX_STANDARD 23) + set(CMAKE_OBJCXX_STANDARD_REQUIRED ON) set(ROBOT_PLATFORM_SOURCES src/platform/macos/MacBackendFactory.cpp src/platform/macos/MacKeyMap.cpp @@ -75,6 +80,10 @@ if(APPLE) src/platform/macos/MacMouseBackend.cpp src/platform/macos/MacScreenBackend.cpp src/platform/macos/MacEventTapBackend.cpp + src/platform/macos/MacWindowBackend.mm + ) + set_source_files_properties(src/platform/macos/MacWindowBackend.mm + PROPERTIES COMPILE_OPTIONS "-fobjc-arc" ) elseif(WIN32) set(ROBOT_PLATFORM_SOURCES @@ -136,9 +145,23 @@ set_target_properties(robot PROPERTIES POSITION_INDEPENDENT_CODE ON) # ── Platform dependencies, scoped per target ────────────────────────────────── if(APPLE) find_library(APPLICATION_SERVICES ApplicationServices REQUIRED) - # ApplicationServices types appear only in .cpp files (behind the backend - # interfaces), so the dependency is PRIVATE. Carbon is intentionally NOT used. - target_link_libraries(robot PRIVATE ${APPLICATION_SERVICES}) + find_library(SCREEN_CAPTURE_KIT ScreenCaptureKit REQUIRED) + find_library(CORE_MEDIA CoreMedia REQUIRED) + find_library(CORE_VIDEO CoreVideo REQUIRED) + find_library(APP_KIT AppKit REQUIRED) + # Most platform framework types appear only in the platform .cpp/.mm files + # (behind the backend interfaces), so those dependencies are PRIVATE. + # CoreVideo is PUBLIC because the installed robot/mac/VideoFrame.h bridge + # hands consumers a CVPixelBufferRef they lock through CoreVideo calls. + # Carbon is intentionally NOT used. + target_link_libraries(robot + PUBLIC + ${CORE_VIDEO} + PRIVATE + ${APPLICATION_SERVICES} + ${SCREEN_CAPTURE_KIT} + ${CORE_MEDIA} + ${APP_KIT}) elseif(WIN32) # user32/gdi32 for SendInput, hooks, GDI capture. Shcore and the DPI context # API are resolved at runtime (see WinDpi.cpp / WinScreenBackend.cpp), so they diff --git a/README.md b/README.md index 7b656ac..08fdc9c 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,18 @@ robot-cpp is built around the distinctions those libraries collapse: - Move the cursor (absolute or smoothly interpolated), click, double-click, drag, and scroll, with buttons through X1/X2 and line- or pixel-unit scrolling. - Press and release physical keys by position, build modifier chords, and type arbitrary Unicode text independent of the active keyboard layout. - Enumerate every display with its own scale factor and both logical and physical bounds, capture any region or a whole monitor at native pixel resolution, sample individual pixels, and encode captures as PNG. +- Enumerate on-screen application windows, query their current bounds and z-order, activate their owning application, and stream a single window as zero-copy video frames carrying the density and content-rect metadata needed to map frame pixels back to desktop coordinates (macOS ScreenCaptureKit; reported as Unsupported elsewhere until those backends exist). - Record global mouse and keyboard input and replay it with its original timing. - Report per-environment capabilities and return typed errors instead of failing silently. ## Supported platforms -| Platform | Backend | Injection | Capture | Recording | -| ----------------- | ----------------------------- | ------------------------------- | ------- | --------- | -| macOS | Quartz (CoreGraphics) | yes (needs Accessibility) | yes (needs Screen Recording) | yes (needs Accessibility) | -| Windows | SendInput + GDI | yes | yes | yes | -| Linux (X11) | XTest + XRandR + XRecord | yes | yes | yes | -| Linux (Wayland) | uinput (opt-in) | keyboard + relative mouse only | no | no | +| Platform | Backend | Injection | Capture | Window streaming | Recording | +| ----------------- | ----------------------------- | ------------------------------- | ------- | ---------------- | --------- | +| macOS | Quartz + ScreenCaptureKit | yes (needs Accessibility) | yes (needs Screen Recording) | yes (macOS 14+, needs Screen Recording) | yes (needs Accessibility) | +| Windows | SendInput + GDI | yes | yes | no | yes | +| Linux (X11) | XTest + XRandR + XRecord | yes | yes | no | yes | +| Linux (Wayland) | uinput (opt-in) | keyboard + relative mouse only | no | no | no | Wayland does not expose a protocol for an unprivileged client to inject input, warp the cursor, or capture the screen. Under a native Wayland session robot-cpp either runs through Xwayland (the X11 backend) or through the kernel-level uinput backend, with the limits above reported explicitly. See [Platform limitations](#platform-limitations) for the details. @@ -207,6 +208,38 @@ if (auto color = screen.pixel({100, 200})) { } ``` +## Windows + +Window enumeration and streaming live on `session->windows()`. Streaming is push-based: frames arrive on an internal capture thread, serialized and in order, as `VideoFrame` values that share (never copy) the platform pixel buffer. Each frame carries the scale and content-rect metadata needed to map its pixels back to window coordinates - `robot::toWindowPoint()` is the one implementation of that formula. On macOS, access the pixels through the typed bridge in `robot/mac/VideoFrame.h`. + +```cpp +robot::Windows& windows = (*session)->windows(); + +// Pick a window (list() needs canEnumerateWindows): +auto list = windows.list(); +const robot::Window& target = (*list)[0]; + +// Stream it. The callback must hand the frame off quickly and never call +// back into robot-cpp. +auto stream = windows.stream( + target.id, {.maxFps = 60.0}, + [&](const robot::VideoFrame& frame) { mailbox.publish(frame); }, + [&](const robot::Error& error) { std::println("stream died: {}", error.message); }); + +// ... later. After stop() returns no callback is running or will start. +stream->stop(); + +// Input coordinate math: re-read the window's current bounds, then map a +// frame pixel to desktop coordinates. +if (auto point = robot::toWindowPoint(frame.info(), pixelX, pixelY)) { + auto bounds = windows.frame(target.id); + robot::LogicalPoint global{bounds->origin.x + point->x, + bounds->origin.y + point->y}; +} +``` + +`topmostWindowId()` reports the topmost normal-layer window in z-order (an ordering query - the honest primitive for "is my target frontmost"), and `activateApplicationFor(windowId)` brings a window's owning application forward; which of its windows ends up on top is the OS's decision. + ## Recording and replay A global event tap observes all mouse and keyboard activity and forwards it as normalized events, which a `Recorder` stamps with elapsed time. Recording is a privileged, platform-limited capability, so check `canRecordEvents` first. Key events are captured as physical keys, so a recording replays by position and is layout-independent. diff --git a/include/robot/Capabilities.h b/include/robot/Capabilities.h index 669afdd..2a8e023 100644 --- a/include/robot/Capabilities.h +++ b/include/robot/Capabilities.h @@ -33,6 +33,8 @@ struct Capabilities { bool canCaptureScreen = false; bool canEnumerateMonitors = false; + bool canEnumerateWindows = false; // Window list, frame, and focus queries. + bool canStreamWindows = false; // Push-based per-window capture streams. bool canRecordEvents = false; // Global input tap / hook. bool requiresAccessibilityPermission = false; diff --git a/include/robot/Error.h b/include/robot/Error.h index 1c7242d..e2a2259 100644 --- a/include/robot/Error.h +++ b/include/robot/Error.h @@ -25,6 +25,8 @@ enum class ErrorCode : std::uint16_t { InvalidArgument, // A referenced monitor id does not exist. MonitorNotFound, + // A referenced window id no longer exists on screen. + WindowNotFound, // A character or key cannot be represented by the active keyboard layout or // injection method. UnmappableInput, @@ -44,6 +46,7 @@ constexpr std::string_view toString(const ErrorCode code) { case ErrorCode::BackendUnavailable: return "BackendUnavailable"; case ErrorCode::InvalidArgument: return "InvalidArgument"; case ErrorCode::MonitorNotFound: return "MonitorNotFound"; + case ErrorCode::WindowNotFound: return "WindowNotFound"; case ErrorCode::UnmappableInput: return "UnmappableInput"; case ErrorCode::CaptureFailed: return "CaptureFailed"; case ErrorCode::EncodeFailed: return "EncodeFailed"; @@ -79,6 +82,10 @@ struct Error { static Error monitorNotFound(const std::uint32_t id) { return {ErrorCode::MonitorNotFound, std::format("Monitor {} not found", id)}; } + static Error windowNotFound(const std::uint32_t id) { + return {ErrorCode::WindowNotFound, + std::format("Window {} no longer exists on screen", id)}; + } static Error unmappableInput(std::string_view detail) { return {ErrorCode::UnmappableInput, std::format("Cannot map input: {}", detail)}; diff --git a/include/robot/Robot.h b/include/robot/Robot.h index f009ad2..0ee9ad2 100644 --- a/include/robot/Robot.h +++ b/include/robot/Robot.h @@ -22,4 +22,8 @@ #include "robot/Recorder.h" #include "robot/Scroll.h" #include "robot/Screen.h" -#include "robot/Session.h" \ No newline at end of file +#include "robot/Session.h" +#include "robot/VideoFrame.h" +#include "robot/Window.h" +#include "robot/Windows.h" +#include "robot/WindowStream.h" \ No newline at end of file diff --git a/include/robot/Session.h b/include/robot/Session.h index 891fe22..505ab53 100644 --- a/include/robot/Session.h +++ b/include/robot/Session.h @@ -12,6 +12,7 @@ #include "robot/Monitor.h" #include "robot/Mouse.h" #include "robot/Screen.h" +#include "robot/Windows.h" namespace robot { namespace backend { @@ -73,6 +74,10 @@ class Session { // implementation (see EventTap). [[nodiscard]] EventTap& eventTap() { return eventTap_; } + // Always present; calls report Unsupported if this platform build has no + // window enumeration/capture implementation (see Windows). + [[nodiscard]] Windows& windows() { return windows_; } + [[nodiscard]] const Capabilities& capabilities() const { return capabilities_; } @@ -96,6 +101,7 @@ class Session { Mouse mouse_; Screen screen_; EventTap eventTap_; + Windows windows_; }; } // namespace robot \ No newline at end of file diff --git a/include/robot/VideoFrame.h b/include/robot/VideoFrame.h new file mode 100644 index 0000000..96bd4fe --- /dev/null +++ b/include/robot/VideoFrame.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "robot/Geometry.h" + +namespace robot { + +// Per-frame metadata for a streamed window frame. The scale fields and the +// content rectangle map buffer pixels back to window points - see +// toWindowPoint() below, which is the one implementation of that formula. +// All fields are zero when the OS omitted the metadata; consumers must refuse +// coordinate-dependent work rather than guess. +struct VideoFrameInfo { + std::uint32_t windowId = 0; + PhysicalSize size; // buffer size in device pixels + double scaleFactor = 0.0; // device pixels per surface point (Retina: 2.0) + double contentScale = 0.0; // downscale applied to fit content in the buffer + // Where the window content sits within the buffer's surface, in surface + // points. The content can sit away from the buffer origin (shadows, + // padding, resize letterboxing), so this is part of the contract. + LogicalRect contentRect; + // Presentation timestamp mapped onto steady_clock (not delivery time), so + // frame age reflects how old the pixels actually are. + std::chrono::steady_clock::time_point capturedAt; +}; + +// Maps a buffer pixel coordinate to window-local logical coordinates: +// +// surfacePoint = pixel / scaleFactor +// windowPoint = (surfacePoint - contentRect.origin) / contentScale +// +// Returns nullopt when the metadata is missing or the pixel lies outside the +// window content area (shadow/letterbox padding) - callers must treat that as +// a refusal, not round it into range. +[[nodiscard]] inline std::optional toWindowPoint( + const VideoFrameInfo& info, const double pixelX, const double pixelY +) { + if (info.scaleFactor <= 0.0 || info.contentScale <= 0.0 || + info.contentRect.size.width <= 0.0 || + info.contentRect.size.height <= 0.0) { + return std::nullopt; + } + + const double contentX = pixelX / info.scaleFactor - info.contentRect.origin.x; + const double contentY = pixelY / info.scaleFactor - info.contentRect.origin.y; + if (contentX < 0.0 || contentX >= info.contentRect.size.width || + contentY < 0.0 || contentY >= info.contentRect.size.height) { + return std::nullopt; + } + + return LogicalPoint{ + .x = contentX / info.contentScale, + .y = contentY / info.contentScale, + }; +} + +// One streamed frame: metadata plus a retained, shareable reference to the +// platform pixel buffer (BGRA layout). Copies share the same buffer - a frame +// is never deep-copied on its way through a consumer's pipeline. +// +// Access the pixels through the typed platform bridge (robot/mac/VideoFrame.h +// on macOS) rather than casting nativeHandle() by hand. A portable pixel +// accessor is deliberately absent until a cross-platform consumer exists. +class VideoFrame { + public: + VideoFrame() = default; + + // Constructed by backends; the buffer's deleter releases the platform + // reference the backend retained. + VideoFrame(const VideoFrameInfo& info, std::shared_ptr nativeBuffer) + : info_(info), nativeBuffer_(std::move(nativeBuffer)) {} + + [[nodiscard]] const VideoFrameInfo& info() const noexcept { return info_; } + + // Borrowed for this frame's lifetime. Prefer the typed platform bridge. + [[nodiscard]] void* nativeHandle() const noexcept { + return nativeBuffer_.get(); + } + + explicit operator bool() const noexcept { return nativeBuffer_ != nullptr; } + + private: + VideoFrameInfo info_; + std::shared_ptr nativeBuffer_; +}; + +} // namespace robot diff --git a/include/robot/Window.h b/include/robot/Window.h new file mode 100644 index 0000000..8c68521 --- /dev/null +++ b/include/robot/Window.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include "robot/Geometry.h" + +namespace robot { + +// One on-screen application window, as reported by enumeration. Windows move +// and resize after enumeration - query Windows::frame() for the current +// bounds before doing coordinate math against one. +struct Window { + std::uint32_t id = 0; + std::int32_t ownerPid = 0; + std::string appName; + std::string title; + // Global logical desktop coordinates (top-left origin), at enumeration time. + LogicalRect frame; +}; + +} // namespace robot diff --git a/include/robot/WindowStream.h b/include/robot/WindowStream.h new file mode 100644 index 0000000..f558057 --- /dev/null +++ b/include/robot/WindowStream.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#include "robot/Error.h" +#include "robot/VideoFrame.h" + +namespace robot { +namespace backend { +class IWindowStreamBackend; +} + +struct WindowStreamOptions { + double maxFps = 60.0; // must be finite and in [1, 1000] + bool showsCursor = true; +}; + +// Invoked on the stream's internal capture thread for every delivered frame, +// serialized and in order. The callback must be brief (hand the frame off; +// VideoFrame copies are cheap), must not throw, and must not call back into +// robot-cpp APIs or touch this stream's handle. +using VideoFrameCallback = std::function; + +// Invoked on the same capture thread (serialized with frame delivery) when +// the stream dies outside stop(): the window closed, the OS revoked capture, +// the display reconfigured. Fires at most once; a death that happens before a +// concurrent stop() is always reported, while stop() winning the race means a +// clean stop and no error. After it fires, isRunning() is false and no +// further frames arrive. Same rules as VideoFrameCallback. +using StreamErrorCallback = std::function; + +// A running window capture stream, returned by Windows::stream(). Move-only +// value type owning the platform stream; destruction stops it. +// +// stop() contract: when stop() returns, no callback is executing and none +// will start. That makes it safe to destroy callback-captured state right +// after stopping. Corollary: never call stop() (or destroy the stream) from +// inside one of its own callbacks - that would deadlock on the drain. +class WindowStream { + public: + explicit WindowStream(std::unique_ptr impl); + ~WindowStream(); + WindowStream(WindowStream&&) noexcept; + WindowStream& operator=(WindowStream&&) noexcept; + WindowStream(const WindowStream&) = delete; + WindowStream& operator=(const WindowStream&) = delete; + + // Idempotent. Blocks until in-flight callbacks have drained. + void stop(); + + // False after stop() or a stream failure. + [[nodiscard]] bool isRunning() const; + + private: + std::unique_ptr impl_; +}; + +} // namespace robot diff --git a/include/robot/Windows.h b/include/robot/Windows.h new file mode 100644 index 0000000..f8e1a83 --- /dev/null +++ b/include/robot/Windows.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +#include "robot/Error.h" +#include "robot/Geometry.h" +#include "robot/Window.h" +#include "robot/WindowStream.h" + +namespace robot { +namespace backend { +class IWindowBackend; +} + +// Window enumeration, geometry/ordering queries, and push-based window +// capture. On platform builds without window support every call returns +// ErrorCode::Unsupported. list() and stream() require canEnumerateWindows / +// canStreamWindows; frame(), topmostWindowId(), and activateApplicationFor() +// work without special permissions on macOS. +// +// The query methods are safe to call from any thread. stream() and each +// returned WindowStream must be driven from one thread at a time. +// +// Obtained from Session::windows(); holds a non-owning pointer to the backend +// (nullptr when the platform build provides no window support). +class Windows { + public: + explicit Windows(backend::IWindowBackend* backend) : backend_(backend) {} + + // On-screen normal-layer windows with titles, front to back. On macOS this + // triggers the Screen Recording permission prompt on first use. + [[nodiscard]] std::expected, Error> list(); + + // Current global-logical frame of a window - re-query before coordinate + // math, windows move. WindowNotFound once it is gone. + [[nodiscard]] std::expected frame( + std::uint32_t windowId + ); + + // Id of the topmost normal-layer window in z-order; nullopt when none is on + // screen. Window-level: another window of the same app being topmost does + // not make the target window topmost. + [[nodiscard]] std::expected, Error> + topmostWindowId(); + + // Brings the application owning the window to the foreground. Application + // semantics: which of its windows ends up frontmost is the OS's decision. + [[nodiscard]] std::expected activateApplicationFor( + std::uint32_t windowId + ); + + // Starts streaming a window. Frames arrive serialized on an internal + // capture thread (see VideoFrameCallback); the stream stops when the + // returned handle is destroyed. + [[nodiscard]] std::expected stream( + std::uint32_t windowId, const WindowStreamOptions& options, + VideoFrameCallback onFrame, StreamErrorCallback onError = nullptr + ); + + private: + backend::IWindowBackend* backend_; +}; + +} // namespace robot diff --git a/include/robot/backend/IPlatformBackend.h b/include/robot/backend/IPlatformBackend.h index 84b1b22..2afe1bb 100644 --- a/include/robot/backend/IPlatformBackend.h +++ b/include/robot/backend/IPlatformBackend.h @@ -5,6 +5,7 @@ #include "robot/backend/IKeyboardBackend.h" #include "robot/backend/IMouseBackend.h" #include "robot/backend/IScreenBackend.h" +#include "robot/backend/IWindowBackend.h" namespace robot::backend { @@ -29,6 +30,9 @@ class IPlatformBackend { // nullptr when this platform build provides no global input tap. [[nodiscard]] virtual IEventTapBackend* eventTap() = 0; + // nullptr when this platform build provides no window enumeration/capture. + [[nodiscard]] virtual IWindowBackend* windows() = 0; + [[nodiscard]] virtual const Capabilities& capabilities() const = 0; }; diff --git a/include/robot/backend/IWindowBackend.h b/include/robot/backend/IWindowBackend.h new file mode 100644 index 0000000..a067c62 --- /dev/null +++ b/include/robot/backend/IWindowBackend.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "robot/Error.h" +#include "robot/Geometry.h" +#include "robot/Window.h" +#include "robot/WindowStream.h" + +namespace robot::backend { + +// One platform window-capture stream. Implementations own the delivery +// thread; stop() must uphold the WindowStream drain contract (no callback +// executing or starting once it returns). +class IWindowStreamBackend { + public: + virtual ~IWindowStreamBackend() = default; + + virtual void stop() = 0; + + [[nodiscard]] virtual bool isRunning() const = 0; +}; + +// Native window enumeration, geometry/focus queries, and push-based window +// capture. Optional per platform build: IPlatformBackend::windows() returns +// nullptr where none of this is implemented, and the Windows facade surfaces +// that as ErrorCode::Unsupported per call - never a silent no-op. +// +// Streaming is push-based because that is how modern capture APIs deliver +// frames (macOS ScreenCaptureKit); the backend owns the delivery thread and +// the caller owns the returned stream. +class IWindowBackend { + public: + virtual ~IWindowBackend() = default; + + // On-screen normal-layer windows with titles, front to back. + [[nodiscard]] virtual std::expected, Error> + enumerateWindows() = 0; + + // Current global-logical frame of a window; WindowNotFound once it is gone. + [[nodiscard]] virtual std::expected windowFrame( + std::uint32_t windowId + ) = 0; + + // Id of the topmost normal-layer window in z-order, nullopt when none is on + // screen. An ordering query, not an input-focus query - but the topmost + // normal window belongs to the active application in practice. + [[nodiscard]] virtual std::expected, Error> + topmostWindowId() = 0; + + // Brings the application owning the window to the foreground. Application + // semantics, not window semantics: which of the app's windows ends up + // frontmost is the OS's decision. + [[nodiscard]] virtual std::expected activateApplicationForWindow( + std::uint32_t windowId + ) = 0; + + // Starts streaming the window. Frames arrive serialized on an internal + // capture thread through onFrame; onError fires (same thread, same + // serialization) when the stream dies outside stop(). + [[nodiscard]] virtual std::expected< + std::unique_ptr, Error> + startStream( + std::uint32_t windowId, const WindowStreamOptions& options, + VideoFrameCallback onFrame, StreamErrorCallback onError + ) = 0; +}; + +} // namespace robot::backend diff --git a/include/robot/mac/VideoFrame.h b/include/robot/mac/VideoFrame.h new file mode 100644 index 0000000..9304822 --- /dev/null +++ b/include/robot/mac/VideoFrame.h @@ -0,0 +1,19 @@ +#pragma once + +// Typed macOS bridge for VideoFrame's platform buffer. Include this from +// macOS-only translation units instead of casting nativeHandle() by hand. + +#include + +#include "robot/VideoFrame.h" + +namespace robot::mac { + +// The frame's pixel buffer, borrowed for the frame's lifetime (the VideoFrame +// keeps its retain). BGRA layout; lock via CVPixelBufferLockBaseAddress for +// CPU access. +[[nodiscard]] inline CVPixelBufferRef cvPixelBuffer(const VideoFrame& frame) { + return static_cast(frame.nativeHandle()); +} + +} // namespace robot::mac diff --git a/src/common/Session.cpp b/src/common/Session.cpp index ca2801b..4596ed3 100644 --- a/src/common/Session.cpp +++ b/src/common/Session.cpp @@ -25,7 +25,8 @@ Session::Session( keyboard_(backend_->keyboard()), mouse_(backend_->mouse()), screen_(backend_->screen()), - eventTap_(backend_->eventTap()) {} + eventTap_(backend_->eventTap()), + windows_(backend_->windows()) {} // Defined here, where IPlatformBackend is complete, so unique_ptr can destroy it. Session::~Session() = default; diff --git a/src/common/Windows.cpp b/src/common/Windows.cpp new file mode 100644 index 0000000..0928f47 --- /dev/null +++ b/src/common/Windows.cpp @@ -0,0 +1,88 @@ +#include "robot/Windows.h" + +#include +#include +#include + +#include "robot/backend/IWindowBackend.h" + +namespace robot { + +// ── WindowStream facade ───────────────────────────────────────────────────── + +WindowStream::WindowStream( + std::unique_ptr impl +) + : impl_(std::move(impl)) {} + +WindowStream::~WindowStream() { + if (impl_ != nullptr) impl_->stop(); +} + +WindowStream::WindowStream(WindowStream&&) noexcept = default; +WindowStream& WindowStream::operator=(WindowStream&&) noexcept = default; + +void WindowStream::stop() { + if (impl_ != nullptr) impl_->stop(); +} + +bool WindowStream::isRunning() const { + return impl_ != nullptr && impl_->isRunning(); +} + +// ── Windows facade ────────────────────────────────────────────────────────── + +namespace { + +constexpr std::string_view kNoBackend = + "window enumeration/capture is not implemented on this platform build"; + +} // namespace + +std::expected, Error> Windows::list() { + if (backend_ == nullptr) return std::unexpected(Error::unsupported(kNoBackend)); + return backend_->enumerateWindows(); +} + +std::expected Windows::frame(const std::uint32_t windowId) { + if (backend_ == nullptr) return std::unexpected(Error::unsupported(kNoBackend)); + return backend_->windowFrame(windowId); +} + +std::expected, Error> Windows::topmostWindowId() { + if (backend_ == nullptr) return std::unexpected(Error::unsupported(kNoBackend)); + return backend_->topmostWindowId(); +} + +std::expected Windows::activateApplicationFor( + const std::uint32_t windowId +) { + if (backend_ == nullptr) return std::unexpected(Error::unsupported(kNoBackend)); + return backend_->activateApplicationForWindow(windowId); +} + +std::expected Windows::stream( + const std::uint32_t windowId, const WindowStreamOptions& options, + VideoFrameCallback onFrame, StreamErrorCallback onError +) { + if (backend_ == nullptr) return std::unexpected(Error::unsupported(kNoBackend)); + if (onFrame == nullptr) { + return std::unexpected( + Error::invalidArgument("stream() requires a frame callback") + ); + } + if (!std::isfinite(options.maxFps) || options.maxFps < 1.0 || + options.maxFps > 1000.0) { + return std::unexpected(Error::invalidArgument(std::format( + "maxFps must be a finite value in [1, 1000], got {}", options.maxFps + ))); + } + + auto stream = backend_->startStream( + windowId, options, std::move(onFrame), std::move(onError) + ); + if (!stream) return std::unexpected(stream.error()); + return WindowStream(std::move(*stream)); +} + +} // namespace robot diff --git a/src/platform/linux/LinuxPlatformBackend.h b/src/platform/linux/LinuxPlatformBackend.h index cab9c40..7525984 100644 --- a/src/platform/linux/LinuxPlatformBackend.h +++ b/src/platform/linux/LinuxPlatformBackend.h @@ -23,6 +23,8 @@ class X11PlatformBackend final : public backend::IPlatformBackend { backend::IMouseBackend& mouse() override { return *mouse_; } backend::IScreenBackend& screen() override { return *screen_; } backend::IEventTapBackend* eventTap() override { return eventTap_.get(); } + // Window enumeration/capture is not implemented on this platform yet. + backend::IWindowBackend* windows() override { return nullptr; } const Capabilities& capabilities() const override { return capabilities_; } private: @@ -49,6 +51,7 @@ class UinputPlatformBackend final : public backend::IPlatformBackend { backend::IMouseBackend& mouse() override { return *device_; } backend::IScreenBackend& screen() override { return *screen_; } backend::IEventTapBackend* eventTap() override { return nullptr; } + backend::IWindowBackend* windows() override { return nullptr; } const Capabilities& capabilities() const override { return capabilities_; } private: diff --git a/src/platform/macos/MacBackendFactory.cpp b/src/platform/macos/MacBackendFactory.cpp index f181307..30635eb 100644 --- a/src/platform/macos/MacBackendFactory.cpp +++ b/src/platform/macos/MacBackendFactory.cpp @@ -36,6 +36,11 @@ Capabilities buildCapabilities(const bool ax, const bool sr) { c.canCaptureScreen = sr; + // Window titles from CGWindowList and ScreenCaptureKit enumeration are both + // gated behind Screen Recording, same as pixel capture. + c.canEnumerateWindows = sr; + c.canStreamWindows = sr; + c.requiresAccessibilityPermission = true; c.requiresScreenRecordingPermission = true; return c; diff --git a/src/platform/macos/MacPlatformBackend.h b/src/platform/macos/MacPlatformBackend.h index 7aa6653..f9420b2 100644 --- a/src/platform/macos/MacPlatformBackend.h +++ b/src/platform/macos/MacPlatformBackend.h @@ -6,6 +6,7 @@ #include "MacKeyboardBackend.h" #include "MacMouseBackend.h" #include "MacScreenBackend.h" +#include "MacWindowBackend.h" #include "robot/backend/IPlatformBackend.h" namespace robot::mac { @@ -19,12 +20,14 @@ class MacPlatformBackend final : public backend::IPlatformBackend { keyboard_(std::make_unique()), mouse_(std::make_unique()), screen_(std::make_unique()), - eventTap_(std::make_unique()) {} + eventTap_(std::make_unique()), + windows_(std::make_unique()) {} backend::IKeyboardBackend& keyboard() override { return *keyboard_; } backend::IMouseBackend& mouse() override { return *mouse_; } backend::IScreenBackend& screen() override { return *screen_; } backend::IEventTapBackend* eventTap() override { return eventTap_.get(); } + backend::IWindowBackend* windows() override { return windows_.get(); } const Capabilities& capabilities() const override { return capabilities_; } private: @@ -33,6 +36,7 @@ class MacPlatformBackend final : public backend::IPlatformBackend { std::unique_ptr mouse_; std::unique_ptr screen_; std::unique_ptr eventTap_; + std::unique_ptr windows_; }; } // namespace robot::mac \ No newline at end of file diff --git a/src/platform/macos/MacWindowBackend.h b/src/platform/macos/MacWindowBackend.h new file mode 100644 index 0000000..07ffdbf --- /dev/null +++ b/src/platform/macos/MacWindowBackend.h @@ -0,0 +1,37 @@ +#pragma once + +#include "robot/backend/IWindowBackend.h" + +namespace robot::mac { + +// ScreenCaptureKit-backed window enumeration and streaming, CoreGraphics +// window/focus queries, AppKit activation. Stateless: every stream returned by +// startStream is a self-contained handle owning its own delivery queue and +// lifetime channel, so the backend itself carries no per-stream state. This +// header stays free of ObjC types so C++ translation units can include it; the +// implementation lives in the .mm. +class MacWindowBackend final : public backend::IWindowBackend { + public: + [[nodiscard]] std::expected, Error> enumerateWindows() + override; + + [[nodiscard]] std::expected windowFrame( + std::uint32_t windowId + ) override; + + [[nodiscard]] std::expected, Error> + topmostWindowId() override; + + [[nodiscard]] std::expected activateApplicationForWindow( + std::uint32_t windowId + ) override; + + [[nodiscard]] std::expected< + std::unique_ptr, Error> + startStream( + std::uint32_t windowId, const WindowStreamOptions& options, + VideoFrameCallback onFrame, StreamErrorCallback onError + ) override; +}; + +} // namespace robot::mac diff --git a/src/platform/macos/MacWindowBackend.mm b/src/platform/macos/MacWindowBackend.mm new file mode 100644 index 0000000..5c1467d --- /dev/null +++ b/src/platform/macos/MacWindowBackend.mm @@ -0,0 +1,513 @@ +#include "MacWindowBackend.h" + +#import +#import +#import +#import + +#include + +#include +#include +#include +#include + +namespace robot::mac { + +// Shared between the stream handle and the ObjC output object. The output +// keeps its own shared_ptr, so callbacks that race stop() (the sample queue is +// not drained synchronously) find a deactivated channel instead of dangling +// pointers, and the caller-supplied callbacks stay alive as long as any +// callback could still reference them. +struct StreamChannel { + VideoFrameCallback onFrame; + StreamErrorCallback onError; + dispatch_queue_t queue = nil; // sample handler queue; serializes onError too + std::uint32_t windowId = 0; + std::atomic active{false}; +}; + +} // namespace robot::mac + +// Receives frames on the stream's sample handler queue. The handler only +// retains the pixel buffer and forwards it through the channel - consumers are +// documented to hand the frame off immediately. +@interface RobotWindowStreamOutput : NSObject { + @public + std::shared_ptr channel; +} +@end + +@implementation RobotWindowStreamOutput + +- (void)stream:(SCStream*)stream + didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer + ofType:(SCStreamOutputType)type { + const auto chan = self->channel; + if (chan == nullptr || !chan->active.load()) return; + if (type != SCStreamOutputTypeScreen) return; + if (!CMSampleBufferIsValid(sampleBuffer)) return; + + // ScreenCaptureKit delivers idle/blank samples too; only complete frames + // carry new content. The same attachment dictionary carries the scale and + // content-rect metadata needed to map capture pixels back to window points. + double scaleFactor = 0.0; + double contentScale = 0.0; + CGRect contentRect = CGRectZero; + std::optional displayTicks; + CFArrayRef attachments = + CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, false); + if (attachments != nullptr && CFArrayGetCount(attachments) > 0) { + const auto* attachment = static_cast( + CFArrayGetValueAtIndex(attachments, 0) + ); + const auto* statusNumber = static_cast(CFDictionaryGetValue( + attachment, (__bridge CFStringRef)SCStreamFrameInfoStatus + )); + if (statusNumber != nullptr) { + std::int32_t status = 0; + CFNumberGetValue(statusNumber, kCFNumberSInt32Type, &status); + if (status != SCFrameStatusComplete) return; + } + const auto* scaleNumber = static_cast(CFDictionaryGetValue( + attachment, (__bridge CFStringRef)SCStreamFrameInfoScaleFactor + )); + if (scaleNumber != nullptr) { + CFNumberGetValue(scaleNumber, kCFNumberFloat64Type, &scaleFactor); + } + const auto* contentScaleNumber = + static_cast(CFDictionaryGetValue( + attachment, (__bridge CFStringRef)SCStreamFrameInfoContentScale + )); + if (contentScaleNumber != nullptr) { + CFNumberGetValue( + contentScaleNumber, kCFNumberFloat64Type, &contentScale + ); + } + const auto* contentRectDict = + static_cast(CFDictionaryGetValue( + attachment, (__bridge CFStringRef)SCStreamFrameInfoContentRect + )); + if (contentRectDict != nullptr) { + CGRectMakeWithDictionaryRepresentation(contentRectDict, &contentRect); + } + const auto* displayTimeNumber = + static_cast(CFDictionaryGetValue( + attachment, (__bridge CFStringRef)SCStreamFrameInfoDisplayTime + )); + if (displayTimeNumber != nullptr) { + std::int64_t ticks = 0; + CFNumberGetValue(displayTimeNumber, kCFNumberSInt64Type, &ticks); + if (ticks > 0) displayTicks = static_cast(ticks); + } + } + + CVPixelBufferRef pixels = CMSampleBufferGetImageBuffer(sampleBuffer); + if (pixels == nullptr) return; + + // Stamp the frame with when it was presented, not when this callback ran - + // a sample delayed inside ScreenCaptureKit must not look fresh to + // freshness-gated consumers. SCStreamFrameInfoDisplayTime is the display + // time in mach absolute ticks, the same clock mach_absolute_time() reads, + // so the age subtraction stays within one clock domain. When the attachment + // is absent, fall back to the sample's presentation timestamp measured + // against the CoreMedia host clock it is produced on. + auto capturedAt = std::chrono::steady_clock::now(); + if (displayTicks) { + static const double nanosPerTick = [] { + mach_timebase_info_data_t timebase{}; + mach_timebase_info(&timebase); + return static_cast(timebase.numer) / + static_cast(timebase.denom); + }(); + const double ageNanos = + (static_cast(mach_absolute_time()) - + static_cast(*displayTicks)) * + nanosPerTick; + capturedAt -= std::chrono::duration_cast( + std::chrono::duration(ageNanos) + ); + } else if (const CMTime presentation = + CMSampleBufferGetPresentationTimeStamp(sampleBuffer); + CMTIME_IS_VALID(presentation)) { + const CMTime hostNow = CMClockGetTime(CMClockGetHostTimeClock()); + const double ageSeconds = + CMTimeGetSeconds(CMTimeSubtract(hostNow, presentation)); + capturedAt -= std::chrono::duration_cast( + std::chrono::duration(ageSeconds) + ); + } + + CVPixelBufferRetain(pixels); + std::shared_ptr buffer(pixels, [](void* pointer) { + CVPixelBufferRelease(static_cast(pointer)); + }); + + chan->onFrame(robot::VideoFrame( + robot::VideoFrameInfo{ + .windowId = chan->windowId, + .size = + {.width = + static_cast(CVPixelBufferGetWidth(pixels)), + .height = + static_cast(CVPixelBufferGetHeight(pixels))}, + .scaleFactor = scaleFactor, + .contentScale = contentScale, + .contentRect = + {.origin = {.x = contentRect.origin.x, .y = contentRect.origin.y}, + .size = + {.width = contentRect.size.width, + .height = contentRect.size.height}}, + .capturedAt = capturedAt, + }, + std::move(buffer) + )); +} + +// Shared death path for didStopWithError and streamDidBecomeInactive. The +// active flag flips synchronously on the delegate thread, so a death that +// happens before a concurrent stop() wins the exchange and its report cannot +// be suppressed; delivery still hops onto the sample handler queue so it is +// serialized with frame delivery and covered by stop()'s drain. +static void reportStreamDeath( + const std::shared_ptr& chan, NSString* message +) { + if (chan == nullptr || chan->queue == nil) return; + if (!chan->active.exchange(false)) return; // stop() already initiated + if (chan->onError == nullptr) return; + dispatch_async(chan->queue, ^{ + chan->onError(robot::Error::captureFailed(message.UTF8String)); + }); +} + +- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error { + reportStreamDeath(self->channel, error.localizedDescription); +} + +- (void)streamDidBecomeInactive:(SCStream*)stream { + reportStreamDeath( + self->channel, + @"stream became inactive (the captured window closed or capture ended)" + ); +} + +@end + +namespace robot::mac { + +namespace { + +// Borrowed CFDictionary values; the owning array keeps them alive. +std::int32_t intValue(CFDictionaryRef dict, CFStringRef key) { + const auto* number = + static_cast(CFDictionaryGetValue(dict, key)); + if (number == nullptr) return 0; + std::int32_t value = 0; + CFNumberGetValue(number, kCFNumberSInt32Type, &value); + return value; +} + +std::expected fetchShareableContent() { + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block SCShareableContent* content = nil; + __block NSError* error = nil; + [SCShareableContent + getShareableContentExcludingDesktopWindows:YES + onScreenWindowsOnly:YES + completionHandler:^( + SCShareableContent* result, NSError* err + ) { + content = result; + error = err; + dispatch_semaphore_signal(semaphore); + }]; + + const auto deadline = + dispatch_time(DISPATCH_TIME_NOW, 5 * static_cast(NSEC_PER_SEC)); + if (dispatch_semaphore_wait(semaphore, deadline) != 0) { + return std::unexpected( + Error::captureFailed("timed out enumerating shareable content") + ); + } + if (error != nil) { + if (error.code == SCStreamErrorUserDeclined) { + return std::unexpected(Error::permissionDenied( + "Screen Recording is required for window enumeration and capture; " + "grant it in System Settings > Privacy & Security > Screen Recording" + )); + } + return std::unexpected( + Error::captureFailed(error.localizedDescription.UTF8String) + ); + } + return content; +} + +// Owns the stream, its ObjC output, and the delivery queue. stop() deactivates +// the channel, stops capture, then drains the delivery queue, upholding the +// WindowStream contract that no callback is executing once stop() returns. +class MacWindowStream final : public backend::IWindowStreamBackend { + public: + MacWindowStream( + SCStream* stream, RobotWindowStreamOutput* output, + dispatch_queue_t queue, std::shared_ptr channel + ) + : stream_(stream), + output_(output), + queue_(queue), + channel_(std::move(channel)) {} + + ~MacWindowStream() override { stop(); } + + void stop() override { + if (stream_ == nil) return; + channel_->active.store(false); + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [stream_ stopCaptureWithCompletionHandler:^(NSError* err) { + (void)err; // already stopping; nothing to do differently on error + dispatch_semaphore_signal(semaphore); + }]; + const auto deadline = dispatch_time( + DISPATCH_TIME_NOW, 2 * static_cast(NSEC_PER_SEC) + ); + dispatch_semaphore_wait(semaphore, deadline); + + // Drain: callbacks run serially on queue_, so once this empty block has + // run, no callback is executing and the deactivated channel stops any + // new one from doing work. + dispatch_sync(queue_, ^{ + }); + + stream_ = nil; + output_ = nil; + queue_ = nil; + } + + [[nodiscard]] bool isRunning() const override { + return channel_->active.load(); + } + + private: + SCStream* stream_ = nil; + RobotWindowStreamOutput* output_ = nil; + dispatch_queue_t queue_ = nil; + std::shared_ptr channel_; +}; + +} // namespace + +std::expected, Error> MacWindowBackend::enumerateWindows() { + auto contentResult = fetchShareableContent(); + if (!contentResult) return std::unexpected(contentResult.error()); + + std::vector windows; + for (SCWindow* window in (*contentResult).windows) { + if (window.windowLayer != 0) continue; + if (window.title == nil || window.title.length == 0) continue; + windows.push_back(Window{ + .id = window.windowID, + .ownerPid = window.owningApplication != nil + ? window.owningApplication.processID + : 0, + .appName = window.owningApplication != nil + ? window.owningApplication.applicationName.UTF8String + : "", + .title = window.title.UTF8String, + .frame = + LogicalRect{ + .origin = + {.x = window.frame.origin.x, .y = window.frame.origin.y}, + .size = + {.width = window.frame.size.width, + .height = window.frame.size.height}, + }, + }); + } + + return windows; +} + +std::expected MacWindowBackend::windowFrame( + const std::uint32_t windowId +) { + CFArrayRef windows = CGWindowListCopyWindowInfo( + kCGWindowListOptionIncludingWindow, windowId + ); + if (windows == nullptr) { + return std::unexpected(Error::windowNotFound(windowId)); + } + + std::optional frame; + if (CFArrayGetCount(windows) > 0) { + const auto* info = + static_cast(CFArrayGetValueAtIndex(windows, 0)); + const auto* boundsDict = static_cast( + CFDictionaryGetValue(info, kCGWindowBounds) + ); + CGRect bounds = CGRectZero; + if (boundsDict != nullptr && + CGRectMakeWithDictionaryRepresentation(boundsDict, &bounds)) { + frame = LogicalRect{ + .origin = {.x = bounds.origin.x, .y = bounds.origin.y}, + .size = {.width = bounds.size.width, .height = bounds.size.height}, + }; + } + } + CFRelease(windows); + + if (!frame) return std::unexpected(Error::windowNotFound(windowId)); + return *frame; +} + +std::expected, Error> +MacWindowBackend::topmostWindowId() { + CFArrayRef windows = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID + ); + if (windows == nullptr) { + return std::unexpected( + Error::platformError("CGWindowListCopyWindowInfo returned null") + ); + } + + std::optional windowId; + for (CFIndex i = 0; i < CFArrayGetCount(windows); ++i) { + const auto* info = + static_cast(CFArrayGetValueAtIndex(windows, i)); + if (intValue(info, kCGWindowLayer) != 0) continue; + windowId = static_cast(intValue(info, kCGWindowNumber)); + break; + } + + CFRelease(windows); + return windowId; +} + +std::expected MacWindowBackend::activateApplicationForWindow( + const std::uint32_t windowId +) { + CFArrayRef windows = CGWindowListCopyWindowInfo( + kCGWindowListOptionIncludingWindow, windowId + ); + std::int32_t pid = 0; + if (windows != nullptr) { + if (CFArrayGetCount(windows) > 0) { + const auto* info = + static_cast(CFArrayGetValueAtIndex(windows, 0)); + pid = intValue(info, kCGWindowOwnerPID); + } + CFRelease(windows); + } + if (pid == 0) return std::unexpected(Error::windowNotFound(windowId)); + + NSRunningApplication* app = + [NSRunningApplication runningApplicationWithProcessIdentifier:pid]; + if (app == nil || app.terminated) { + return std::unexpected(Error::windowNotFound(windowId)); + } + if (![app activateWithOptions:0]) { + return std::unexpected( + Error::platformError("NSRunningApplication activation was refused") + ); + } + return {}; +} + +std::expected, Error> +MacWindowBackend::startStream( + const std::uint32_t windowId, const WindowStreamOptions& options, + VideoFrameCallback onFrame, StreamErrorCallback onError +) { + // SCContentFilter.pointPixelScale and .contentRect - the metadata the whole + // coordinate contract rests on - exist since macOS 14. + if (@available(macOS 14.0, *)) { + // Streaming is available; continue below. + } else { + return std::unexpected( + Error::unsupported("window streaming requires macOS 14 or newer") + ); + } + + auto contentResult = fetchShareableContent(); + if (!contentResult) return std::unexpected(contentResult.error()); + + SCWindow* scWindow = nil; + for (SCWindow* window in (*contentResult).windows) { + if (window.windowID == windowId) { + scWindow = window; + break; + } + } + if (scWindow == nil) return std::unexpected(Error::windowNotFound(windowId)); + + SCContentFilter* filter = + [[SCContentFilter alloc] initWithDesktopIndependentWindow:scWindow]; + SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init]; + const double scale = static_cast(filter.pointPixelScale); + config.width = static_cast(filter.contentRect.size.width * scale); + config.height = static_cast(filter.contentRect.size.height * scale); + config.pixelFormat = kCVPixelFormatType_32BGRA; + config.minimumFrameInterval = + CMTimeMake(1, static_cast(options.maxFps)); + config.queueDepth = 3; + config.showsCursor = options.showsCursor ? YES : NO; + + dispatch_queue_t queue = + dispatch_queue_create("robot.window-stream", DISPATCH_QUEUE_SERIAL); + auto channel = std::make_shared(); + channel->onFrame = std::move(onFrame); + channel->onError = std::move(onError); + channel->queue = queue; + channel->windowId = windowId; + channel->active.store(true); + + RobotWindowStreamOutput* output = [[RobotWindowStreamOutput alloc] init]; + output->channel = channel; + SCStream* stream = [[SCStream alloc] initWithFilter:filter + configuration:config + delegate:output]; + + NSError* addError = nil; + [stream addStreamOutput:output + type:SCStreamOutputTypeScreen + sampleHandlerQueue:queue + error:&addError]; + if (addError != nil) { + return std::unexpected( + Error::captureFailed(addError.localizedDescription.UTF8String) + ); + } + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block NSError* startError = nil; + [stream startCaptureWithCompletionHandler:^(NSError* err) { + startError = err; + dispatch_semaphore_signal(semaphore); + }]; + const auto deadline = + dispatch_time(DISPATCH_TIME_NOW, 5 * static_cast(NSEC_PER_SEC)); + if (dispatch_semaphore_wait(semaphore, deadline) != 0) { + // Startup may still complete later; deactivate the channel and issue a + // fire-and-forget stop so a late success never delivers frames. + // startError is deliberately not read here - the completion handler may + // still be about to write it. + channel->active.store(false); + [stream stopCaptureWithCompletionHandler:^(NSError*) { + }]; + return std::unexpected(Error::captureFailed("timed out starting capture")); + } + if (startError != nil) { + channel->active.store(false); + return std::unexpected( + Error::captureFailed(startError.localizedDescription.UTF8String) + ); + } + + return std::make_unique( + stream, output, queue, std::move(channel) + ); +} + +} // namespace robot::mac diff --git a/src/platform/windows/WinPlatformBackend.h b/src/platform/windows/WinPlatformBackend.h index a5e1a26..4abc183 100644 --- a/src/platform/windows/WinPlatformBackend.h +++ b/src/platform/windows/WinPlatformBackend.h @@ -25,6 +25,8 @@ class WinPlatformBackend final : public backend::IPlatformBackend { backend::IMouseBackend& mouse() override { return *mouse_; } backend::IScreenBackend& screen() override { return *screen_; } backend::IEventTapBackend* eventTap() override { return eventTap_.get(); } + // Window enumeration/capture is not implemented on this platform yet. + backend::IWindowBackend* windows() override { return nullptr; } const Capabilities& capabilities() const override { return capabilities_; } private: diff --git a/tests/unit/support/MockBackend.h b/tests/unit/support/MockBackend.h index cd4cf78..a882238 100644 --- a/tests/unit/support/MockBackend.h +++ b/tests/unit/support/MockBackend.h @@ -115,6 +115,7 @@ class MockPlatformBackend final : public backend::IPlatformBackend { backend::IMouseBackend& mouse() override { return mouse_; } backend::IScreenBackend& screen() override { return screen_; } backend::IEventTapBackend* eventTap() override { return nullptr; } + backend::IWindowBackend* windows() override { return nullptr; } const Capabilities& capabilities() const override { return capabilities_; } std::vector& log() { return log_; }