From ecdf94e9dbd8ddc8f74f43ae7e31154c96da2795 Mon Sep 17 00:00:00 2001 From: LiJianying Date: Sat, 31 Oct 2026 23:47:38 +0800 Subject: [PATCH] Add screenshot and screen recording APIs for macOS and Windows --- include/nativeapi.h | 6 + src/CMakeLists.txt | 3 + src/capi/capture_source_c.cpp | 74 +++ src/capi/capture_source_c.h | 108 ++++ src/capi/color_c.cpp | 6 + src/capi/color_c.h | 4 + src/capi/geometry_c.cpp | 24 + src/capi/geometry_c.h | 16 + src/capi/screen_capturer_c.cpp | 133 +++++ src/capi/screen_capturer_c.h | 122 ++++ src/capi/screen_recorder_c.cpp | 262 +++++++++ src/capi/screen_recorder_c.h | 220 ++++++++ src/capture_source.cpp | 50 ++ src/capture_source.h | 72 +++ src/foundation/id_allocator.h | 10 + .../android/screen_capture_android.cpp | 17 + src/platform/ios/screen_capture_ios.mm | 17 + src/platform/linux/screen_capture_linux.cpp | 17 + src/platform/macos/screen_capture_macos.mm | 528 ++++++++++++++++++ src/platform/ohos/screen_capture_ohos.cpp | 17 + .../windows/screen_capture_windows.cpp | 473 ++++++++++++++++ src/screen_capture_backend.h | 101 ++++ src/screen_capture_common.cpp | 177 ++++++ src/screen_capturer.cpp | 101 ++++ src/screen_capturer.h | 88 +++ src/screen_recorder.cpp | 218 ++++++++ src/screen_recorder.h | 144 +++++ tests/CMakeLists.txt | 10 + tests/build_capture_windows.ps1 | 10 + tests/run_capture_windows.ps1 | 52 ++ tests/screen_capture_macos_smoke.mm | 120 ++++ tests/screen_capture_test.cpp | 191 +++++++ tests/screen_capture_windows_smoke.cpp | 194 +++++++ 33 files changed, 3585 insertions(+) create mode 100644 src/capi/capture_source_c.cpp create mode 100644 src/capi/capture_source_c.h create mode 100644 src/capi/screen_capturer_c.cpp create mode 100644 src/capi/screen_capturer_c.h create mode 100644 src/capi/screen_recorder_c.cpp create mode 100644 src/capi/screen_recorder_c.h create mode 100644 src/capture_source.cpp create mode 100644 src/capture_source.h create mode 100644 src/platform/android/screen_capture_android.cpp create mode 100644 src/platform/ios/screen_capture_ios.mm create mode 100644 src/platform/linux/screen_capture_linux.cpp create mode 100644 src/platform/macos/screen_capture_macos.mm create mode 100644 src/platform/ohos/screen_capture_ohos.cpp create mode 100644 src/platform/windows/screen_capture_windows.cpp create mode 100644 src/screen_capture_backend.h create mode 100644 src/screen_capture_common.cpp create mode 100644 src/screen_capturer.cpp create mode 100644 src/screen_capturer.h create mode 100644 src/screen_recorder.cpp create mode 100644 src/screen_recorder.h create mode 100644 tests/build_capture_windows.ps1 create mode 100644 tests/run_capture_windows.ps1 create mode 100644 tests/screen_capture_macos_smoke.mm create mode 100644 tests/screen_capture_test.cpp create mode 100644 tests/screen_capture_windows_smoke.cpp diff --git a/include/nativeapi.h b/include/nativeapi.h index 2c25e2c6..84d59226 100644 --- a/include/nativeapi.h +++ b/include/nativeapi.h @@ -8,6 +8,7 @@ #include "../src/accessibility_manager.h" #include "../src/app_info.h" #include "../src/application.h" +#include "../src/capture_source.h" #include "../src/clipboard.h" #include "../src/device_info.h" #include "../src/dialog.h" @@ -29,6 +30,8 @@ #include "../src/placement.h" #include "../src/positioning_strategy.h" #include "../src/preferences.h" +#include "../src/screen_capturer.h" +#include "../src/screen_recorder.h" #include "../src/secure_storage.h" #include "../src/shortcut.h" #include "../src/shortcut_manager.h" @@ -47,6 +50,7 @@ #include "../src/capi/accessibility_manager_c.h" #include "../src/capi/app_info_c.h" #include "../src/capi/application_c.h" +#include "../src/capi/capture_source_c.h" #include "../src/capi/clipboard_c.h" #include "../src/capi/color_c.h" #include "../src/capi/common_c.h" @@ -69,6 +73,8 @@ #include "../src/capi/placement_c.h" #include "../src/capi/positioning_strategy_c.h" #include "../src/capi/preferences_c.h" +#include "../src/capi/screen_capturer_c.h" +#include "../src/capi/screen_recorder_c.h" #include "../src/capi/secure_storage_c.h" #include "../src/capi/shortcut_c.h" #include "../src/capi/shortcut_manager_c.h" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 50cfc1e0..eec26b9a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -98,6 +98,8 @@ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") target_link_libraries(nativeapi PUBLIC PkgConfig::GTK PkgConfig::X11 PkgConfig::XI pthread) elseif(APPLE) target_link_libraries(nativeapi PUBLIC "-framework Cocoa") + target_link_libraries(nativeapi PUBLIC "-weak_framework ScreenCaptureKit" "-framework AVFoundation" + "-framework CoreMedia" "-framework CoreVideo" "-framework VideoToolbox") target_link_libraries(nativeapi PUBLIC "-framework QuartzCore") target_link_libraries(nativeapi PUBLIC "-framework Carbon") target_link_libraries(nativeapi PUBLIC "-framework ServiceManagement") @@ -111,4 +113,5 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "OHOS") set_target_properties(nativeapi PROPERTIES POSITION_INDEPENDENT_CODE ON) elseif(WIN32) target_link_libraries(nativeapi PUBLIC user32 shell32 ole32 uuid comctl32 dwmapi gdiplus crypt32 advapi32 version) + target_link_libraries(nativeapi PUBLIC d3d11 dxgi windowsapp runtimeobject mfplat mfreadwrite mfuuid windowscodecs) endif () diff --git a/src/capi/capture_source_c.cpp b/src/capi/capture_source_c.cpp new file mode 100644 index 00000000..3840a6d2 --- /dev/null +++ b/src/capi/capture_source_c.cpp @@ -0,0 +1,74 @@ +// AUTO-GENERATED. DO NOT EDIT. +// Any manual changes WILL BE LOST when this file is regenerated. + +#include "capture_source_c.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "string_utils_c.h" +#include "user_data.h" +#include "../foundation/handle_table.h" +#include "../display.h" +#include "display_c.h" +#include "../window.h" +#include "window_c.h" +#include "../capture_source.h" + +native_capture_source_t native_capture_source_from_display(native_display_t display) { + try { + auto display_cpp = nativeapi::HandleTable::GetInstance().Resolve(display); + return nativeapi::HandleTable::GetInstance().Insert(nativeapi::CaptureSource::FromDisplay(display_cpp)); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_capture_source_from_display"); + return 0; + } +} + +native_capture_source_t native_capture_source_from_window(native_window_t window) { + try { + auto window_cpp = nativeapi::HandleTable::GetInstance().Resolve(window); + return nativeapi::HandleTable::GetInstance().Insert(nativeapi::CaptureSource::FromWindow(window_cpp)); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_capture_source_from_window"); + return 0; + } +} + +native_capture_source_type_t native_capture_source_get_type(native_capture_source_t capture_source) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(capture_source); + if (!self) { + return (native_capture_source_type_t)NATIVE_CAPTURE_SOURCE_TYPE_NONE; + } + try { + return to_c_capture_source_type(self->GetType()); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_capture_source_get_type"); + return (native_capture_source_type_t)NATIVE_CAPTURE_SOURCE_TYPE_NONE; + } +} + +char* native_capture_source_get_name(native_capture_source_t capture_source) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(capture_source); + if (!self) { + return nullptr; + } + try { + return to_c_str(self->GetName()); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_capture_source_get_name"); + return nullptr; + } +} + +void native_capture_source_free(native_capture_source_t capture_source) { + // The table invalidates the handle itself, so releasing an unknown or + // already-released one is a no-op rather than a double free. + nativeapi::HandleTable::GetInstance().Release(capture_source); +} + diff --git a/src/capi/capture_source_c.h b/src/capi/capture_source_c.h new file mode 100644 index 00000000..1218daf3 --- /dev/null +++ b/src/capi/capture_source_c.h @@ -0,0 +1,108 @@ +// AUTO-GENERATED. DO NOT EDIT. +// Any manual changes WILL BE LOST when this file is regenerated. + +#pragma once + +#include +#include + +#include "common_c.h" +typedef uint64_t native_display_t; +typedef uint64_t native_window_t; + +#include "display_c.h" +#include "window_c.h" + +#if _WIN32 +#define FFI_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FFI_PLUGIN_EXPORT +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + NATIVE_CAPTURE_SOURCE_TYPE_NONE = 0, + NATIVE_CAPTURE_SOURCE_TYPE_DISPLAY = 1, + NATIVE_CAPTURE_SOURCE_TYPE_WINDOW = 2, + NATIVE_CAPTURE_SOURCE_TYPE_UNKNOWN = 3, +} native_capture_source_type_t; + +/// Opaque CaptureSource handle. +/// +/// A generational index into the library's handle table, NOT a pointer: +/// never dereference it, and compare it against NATIVE_INVALID_CAPTURE_SOURCE rather than NULL. +/// Releasing a handle invalidates it; later calls fail safely instead of +/// touching freed memory. +typedef uint64_t native_capture_source_t; + +/// Never refers to a live CaptureSource. +#define NATIVE_INVALID_CAPTURE_SOURCE ((native_capture_source_t)0) + +/// Caller owns the returned handle; release it with native_capture_source_free(). +FFI_PLUGIN_EXPORT +native_capture_source_t native_capture_source_from_display(native_display_t display); + +/// Caller owns the returned handle; release it with native_capture_source_free(). +FFI_PLUGIN_EXPORT +native_capture_source_t native_capture_source_from_window(native_window_t window); + +FFI_PLUGIN_EXPORT +native_capture_source_type_t native_capture_source_get_type(native_capture_source_t capture_source); + +/// Caller owns the returned string; free it with free_c_str(). +FFI_PLUGIN_EXPORT +char* native_capture_source_get_name(native_capture_source_t capture_source); + +/// Releases the caller's reference. Safe to call with an invalid or +/// already-released handle. +FFI_PLUGIN_EXPORT +void native_capture_source_free(native_capture_source_t capture_source); + +#ifdef __cplusplus +} +#endif + +#ifdef __cplusplus +#include "../capture_source.h" +#include "string_utils_c.h" +#include "user_data.h" + +// Conversion helpers between these C types and their C++ originals. + +inline native_capture_source_type_t to_c_capture_source_type(nativeapi::CaptureSourceType value); +inline nativeapi::CaptureSourceType to_cpp_capture_source_type(native_capture_source_type_t value); + +inline native_capture_source_type_t to_c_capture_source_type(nativeapi::CaptureSourceType value) { + switch (value) { + case nativeapi::CaptureSourceType::None: + return NATIVE_CAPTURE_SOURCE_TYPE_NONE; + case nativeapi::CaptureSourceType::Display: + return NATIVE_CAPTURE_SOURCE_TYPE_DISPLAY; + case nativeapi::CaptureSourceType::Window: + return NATIVE_CAPTURE_SOURCE_TYPE_WINDOW; + case nativeapi::CaptureSourceType::Unknown: + return NATIVE_CAPTURE_SOURCE_TYPE_UNKNOWN; + default: + return NATIVE_CAPTURE_SOURCE_TYPE_NONE; + } +} + +inline nativeapi::CaptureSourceType to_cpp_capture_source_type(native_capture_source_type_t value) { + switch (value) { + case NATIVE_CAPTURE_SOURCE_TYPE_NONE: + return nativeapi::CaptureSourceType::None; + case NATIVE_CAPTURE_SOURCE_TYPE_DISPLAY: + return nativeapi::CaptureSourceType::Display; + case NATIVE_CAPTURE_SOURCE_TYPE_WINDOW: + return nativeapi::CaptureSourceType::Window; + case NATIVE_CAPTURE_SOURCE_TYPE_UNKNOWN: + return nativeapi::CaptureSourceType::Unknown; + default: + return static_cast(value); + } +} + +#endif // __cplusplus diff --git a/src/capi/color_c.cpp b/src/capi/color_c.cpp index e8ec5e63..b3281044 100644 --- a/src/capi/color_c.cpp +++ b/src/capi/color_c.cpp @@ -76,3 +76,9 @@ unsigned int native_color_to_argb(native_color_t color) { } } +void native_color_free(native_color_t* value) { + if (!value) { + return; + } +} + diff --git a/src/capi/color_c.h b/src/capi/color_c.h index 64b1c5c6..771acdd9 100644 --- a/src/capi/color_c.h +++ b/src/capi/color_c.h @@ -64,6 +64,10 @@ unsigned int native_color_to_rgba(native_color_t color); FFI_PLUGIN_EXPORT unsigned int native_color_to_argb(native_color_t color); +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_color_free(native_color_t* value); + #ifdef __cplusplus } #endif diff --git a/src/capi/geometry_c.cpp b/src/capi/geometry_c.cpp index a2c16673..c8c8aca6 100644 --- a/src/capi/geometry_c.cpp +++ b/src/capi/geometry_c.cpp @@ -16,6 +16,24 @@ #include "../foundation/handle_table.h" #include "../foundation/geometry.h" +void native_point_free(native_point_t* value) { + if (!value) { + return; + } +} + +void native_size_free(native_size_t* value) { + if (!value) { + return; + } +} + +void native_rectangle_free(native_rectangle_t* value) { + if (!value) { + return; + } +} + native_edge_insets_t native_edge_insets_all(double value) { try { const auto cpp_result = nativeapi::EdgeInsets::All(value); @@ -38,3 +56,9 @@ native_edge_insets_t native_edge_insets_symmetric(double vertical, double horizo } } +void native_edge_insets_free(native_edge_insets_t* value) { + if (!value) { + return; + } +} + diff --git a/src/capi/geometry_c.h b/src/capi/geometry_c.h index 10e850ef..8306ca17 100644 --- a/src/capi/geometry_c.h +++ b/src/capi/geometry_c.h @@ -42,12 +42,28 @@ typedef struct { double left; } native_edge_insets_t; +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_point_free(native_point_t* value); + +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_size_free(native_size_t* value); + +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_rectangle_free(native_rectangle_t* value); + FFI_PLUGIN_EXPORT native_edge_insets_t native_edge_insets_all(double value); FFI_PLUGIN_EXPORT native_edge_insets_t native_edge_insets_symmetric(double vertical, double horizontal); +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_edge_insets_free(native_edge_insets_t* value); + #ifdef __cplusplus } #endif diff --git a/src/capi/screen_capturer_c.cpp b/src/capi/screen_capturer_c.cpp new file mode 100644 index 00000000..d71811f4 --- /dev/null +++ b/src/capi/screen_capturer_c.cpp @@ -0,0 +1,133 @@ +// AUTO-GENERATED. DO NOT EDIT. +// Any manual changes WILL BE LOST when this file is regenerated. + +#include "screen_capturer_c.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "string_utils_c.h" +#include "user_data.h" +#include "event_delivery.h" +#include "../foundation/handle_table.h" +#include "../foundation/geometry.h" +#include "geometry_c.h" +#include "../image.h" +#include "image_c.h" +#include "../capture_source.h" +#include "capture_source_c.h" +#include "../window.h" +#include "window_c.h" +#include "../screen_capturer.h" + +void native_capture_source_selection_options_free(native_capture_source_selection_options_t* value) { + if (!value) { + return; + } + nativeapi::HandleTable::GetInstance().Release(value->parent_window); value->parent_window = 0; +} + +void native_screen_capture_options_free(native_screen_capture_options_t* value) { + if (!value) { + return; + } + native_rectangle_free(&value->crop); +} + +bool native_screen_capturer_is_supported(void) { + try { + return nativeapi::ScreenCapturer::IsSupported(); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_capturer_is_supported"); + return false; + } +} + +bool native_screen_capturer_is_source_selection_supported(void) { + try { + return nativeapi::ScreenCapturer::IsSourceSelectionSupported(); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_capturer_is_source_selection_supported"); + return false; + } +} + +bool native_screen_capturer_is_direct_capture_supported(native_capture_source_type_t type) { + try { + return nativeapi::ScreenCapturer::IsDirectCaptureSupported(to_cpp_capture_source_type(type)); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_capturer_is_direct_capture_supported"); + return false; + } +} + +void native_screen_capturer_select_source(native_capture_source_selection_options_t options, native_screen_capturer_select_source_callback_t callback, void* callback_user_data, native_release_user_data_t callback_release_user_data) { + auto callback_holder = nativeapi::capi::UserData::Make(callback_user_data, callback_release_user_data); + try { + auto options_cpp = to_cpp_capture_source_selection_options(options); + std::function)> callback_cpp; + if (callback) { + callback_cpp = [callback, holder = callback_holder](bool arg0, std::shared_ptr arg1) { + if (holder->revoked()) return; + struct Payload { + bool arg0{}; + native_capture_source_t arg1{}; + }; + auto payload = std::shared_ptr(new Payload{}, [](Payload* value) { + nativeapi::HandleTable::GetInstance().Release(value->arg1); value->arg1 = 0; + delete value; + }); + payload->arg0 = arg0; + payload->arg1 = nativeapi::HandleTable::GetInstance().Insert(arg1); + auto context = std::make_shared(holder); + auto lease = std::make_shared(context, payload, nullptr); + auto delivery = nativeapi::HandleTable::GetInstance().Insert(lease); + callback(payload->arg0, payload->arg1, delivery, holder->get()); + }; + } + nativeapi::ScreenCapturer::SelectSource(options_cpp, callback_cpp); + return; + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_capturer_select_source"); + return; + } +} + +void native_screen_capturer_capture(native_capture_source_t source, native_screen_capture_options_t options, native_screen_capturer_capture_callback_t callback, void* callback_user_data, native_release_user_data_t callback_release_user_data) { + auto callback_holder = nativeapi::capi::UserData::Make(callback_user_data, callback_release_user_data); + try { + auto source_cpp = nativeapi::HandleTable::GetInstance().Resolve(source); + auto options_cpp = to_cpp_screen_capture_options(options); + std::function)> callback_cpp; + if (callback) { + callback_cpp = [callback, holder = callback_holder](bool arg0, std::shared_ptr arg1) { + if (holder->revoked()) return; + struct Payload { + bool arg0{}; + native_image_t arg1{}; + }; + auto payload = std::shared_ptr(new Payload{}, [](Payload* value) { + nativeapi::HandleTable::GetInstance().Release(value->arg1); value->arg1 = 0; + delete value; + }); + payload->arg0 = arg0; + payload->arg1 = nativeapi::HandleTable::GetInstance().Insert(arg1); + auto context = std::make_shared(holder); + auto lease = std::make_shared(context, payload, nullptr); + auto delivery = nativeapi::HandleTable::GetInstance().Insert(lease); + callback(payload->arg0, payload->arg1, delivery, holder->get()); + }; + } + nativeapi::ScreenCapturer::Capture(source_cpp, options_cpp, callback_cpp); + return; + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_capturer_capture"); + return; + } +} + diff --git a/src/capi/screen_capturer_c.h b/src/capi/screen_capturer_c.h new file mode 100644 index 00000000..f9ba328c --- /dev/null +++ b/src/capi/screen_capturer_c.h @@ -0,0 +1,122 @@ +// AUTO-GENERATED. DO NOT EDIT. +// Any manual changes WILL BE LOST when this file is regenerated. + +#pragma once + +#include +#include + +#include "common_c.h" +typedef uint64_t native_capture_source_t; +typedef uint64_t native_image_t; +typedef uint64_t native_window_t; + +#include "capture_source_c.h" +#include "geometry_c.h" +#include "image_c.h" +#include "window_c.h" + +#if _WIN32 +#define FFI_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FFI_PLUGIN_EXPORT +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/// Arguments are borrowed until event_delivery_complete(delivery, ...), which is required exactly once. +typedef void (*native_screen_capturer_select_source_callback_t)(bool arg0, native_capture_source_t arg1, native_event_delivery_t delivery, void* user_data); + +/// Arguments are borrowed until event_delivery_complete(delivery, ...), which is required exactly once. +typedef void (*native_screen_capturer_capture_callback_t)(bool arg0, native_image_t arg1, native_event_delivery_t delivery, void* user_data); + +typedef struct { + bool allow_displays; + bool allow_windows; + native_window_t parent_window; +} native_capture_source_selection_options_t; + +typedef struct { + bool include_cursor; + bool crop_enabled; + native_rectangle_t crop; +} native_screen_capture_options_t; + +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_capture_source_selection_options_free(native_capture_source_selection_options_t* value); + +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_screen_capture_options_free(native_screen_capture_options_t* value); + +FFI_PLUGIN_EXPORT +bool native_screen_capturer_is_supported(void); + +FFI_PLUGIN_EXPORT +bool native_screen_capturer_is_source_selection_supported(void); + +FFI_PLUGIN_EXPORT +bool native_screen_capturer_is_direct_capture_supported(native_capture_source_type_t type); + +FFI_PLUGIN_EXPORT +void native_screen_capturer_select_source(native_capture_source_selection_options_t options, native_screen_capturer_select_source_callback_t callback, void* callback_user_data, native_release_user_data_t callback_release_user_data); + +FFI_PLUGIN_EXPORT +void native_screen_capturer_capture(native_capture_source_t source, native_screen_capture_options_t options, native_screen_capturer_capture_callback_t callback, void* callback_user_data, native_release_user_data_t callback_release_user_data); + +#ifdef __cplusplus +} +#endif + +#ifdef __cplusplus +#include "../screen_capturer.h" +#include "string_utils_c.h" +#include "user_data.h" + +// Conversion helpers between these C types and their C++ originals. + +#include +#include +#include "../foundation/handle_table.h" +inline native_capture_source_selection_options_t to_c_capture_source_selection_options(const nativeapi::CaptureSourceSelectionOptions& value); +inline nativeapi::CaptureSourceSelectionOptions to_cpp_capture_source_selection_options(const native_capture_source_selection_options_t& value); +inline native_screen_capture_options_t to_c_screen_capture_options(const nativeapi::ScreenCaptureOptions& value); +inline nativeapi::ScreenCaptureOptions to_cpp_screen_capture_options(const native_screen_capture_options_t& value); + +inline native_capture_source_selection_options_t to_c_capture_source_selection_options(const nativeapi::CaptureSourceSelectionOptions& value) { + native_capture_source_selection_options_t result = {}; + result.allow_displays = value.allow_displays; + result.allow_windows = value.allow_windows; + result.parent_window = nativeapi::HandleTable::GetInstance().Insert(value.parent_window); + return result; +} + +inline nativeapi::CaptureSourceSelectionOptions to_cpp_capture_source_selection_options(const native_capture_source_selection_options_t& value) { + nativeapi::CaptureSourceSelectionOptions result = {}; + result.allow_displays = value.allow_displays; + result.allow_windows = value.allow_windows; + result.parent_window = nativeapi::HandleTable::GetInstance().Resolve(value.parent_window); + if (value.parent_window && !result.parent_window) throw std::invalid_argument("Invalid struct handle"); + return result; +} + +inline native_screen_capture_options_t to_c_screen_capture_options(const nativeapi::ScreenCaptureOptions& value) { + native_screen_capture_options_t result = {}; + result.include_cursor = value.include_cursor; + result.crop_enabled = value.crop_enabled; + result.crop = to_c_rectangle(value.crop); + return result; +} + +inline nativeapi::ScreenCaptureOptions to_cpp_screen_capture_options(const native_screen_capture_options_t& value) { + nativeapi::ScreenCaptureOptions result = {}; + result.include_cursor = value.include_cursor; + result.crop_enabled = value.crop_enabled; + result.crop = to_cpp_rectangle(value.crop); + return result; +} + +#endif // __cplusplus diff --git a/src/capi/screen_recorder_c.cpp b/src/capi/screen_recorder_c.cpp new file mode 100644 index 00000000..1908f6af --- /dev/null +++ b/src/capi/screen_recorder_c.cpp @@ -0,0 +1,262 @@ +// AUTO-GENERATED. DO NOT EDIT. +// Any manual changes WILL BE LOST when this file is regenerated. + +#include "screen_recorder_c.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "string_utils_c.h" +#include "user_data.h" +#include "event_delivery.h" +#include "../foundation/handle_table.h" +#include "../foundation/geometry.h" +#include "geometry_c.h" +#include "../capture_source.h" +#include "capture_source_c.h" +#include "../screen_capturer.h" +#include "screen_capturer_c.h" +#include "../screen_recorder.h" + +void native_screen_recording_options_free(native_screen_recording_options_t* value) { + if (!value) { + return; + } + native_screen_capture_options_free(&value->capture); +} + +native_screen_recorder_t native_screen_recorder_create(void) { + try { + return nativeapi::HandleTable::GetInstance().Insert( + std::make_shared()); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_create"); + return 0; + } +} + +bool native_screen_recorder_is_supported(void) { + try { + return nativeapi::ScreenRecorder::IsSupported(); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_is_supported"); + return false; + } +} + +bool native_screen_recorder_start(native_screen_recorder_t screen_recorder, native_capture_source_t source, const char* file_path, native_screen_recording_options_t options) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return false; + } + try { + auto source_cpp = nativeapi::HandleTable::GetInstance().Resolve(source); + auto options_cpp = to_cpp_screen_recording_options(options); + return self->Start(source_cpp, std::string(file_path ? file_path : ""), options_cpp); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_start"); + return false; + } +} + +bool native_screen_recorder_stop(native_screen_recorder_t screen_recorder) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return false; + } + try { + return self->Stop(); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_stop"); + return false; + } +} + +bool native_screen_recorder_cancel(native_screen_recorder_t screen_recorder) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return false; + } + try { + return self->Cancel(); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_cancel"); + return false; + } +} + +native_screen_recording_state_t native_screen_recorder_get_state(native_screen_recorder_t screen_recorder) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return (native_screen_recording_state_t)NATIVE_SCREEN_RECORDING_STATE_IDLE; + } + try { + return to_c_screen_recording_state(self->GetState()); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_get_state"); + return (native_screen_recording_state_t)NATIVE_SCREEN_RECORDING_STATE_IDLE; + } +} + +bool native_screen_recorder_is_active(native_screen_recorder_t screen_recorder) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return false; + } + try { + return self->IsActive(); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_is_active"); + return false; + } +} + +double native_screen_recorder_get_duration(native_screen_recorder_t screen_recorder) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return 0; + } + try { + return self->GetDuration(); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_get_duration"); + return 0; + } +} + +char* native_screen_recorder_get_file_path(native_screen_recorder_t screen_recorder) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return nullptr; + } + try { + return to_c_str(self->GetFilePath()); + } catch (...) { + fprintf(stderr, "[nativeapi] %s: unexpected exception\n", "native_screen_recorder_get_file_path"); + return nullptr; + } +} + +void native_screen_recorder_free(native_screen_recorder_t screen_recorder) { + // The table invalidates the handle itself, so releasing an unknown or + // already-released one is a no-op rather than a double free. + nativeapi::HandleTable::GetInstance().Release(screen_recorder); +} + +native_listener_id_t native_screen_recorder_add_listener(native_screen_recorder_t screen_recorder, native_screen_recording_event_callback_t callback, void* user_data, native_release_user_data_t release_user_data) { + auto holder = nativeapi::capi::UserData::Make(user_data, release_user_data); + if (!callback) { + return 0; + } + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return 0; + } + try { + return static_cast(self->AddListener( + [callback, holder](const nativeapi::ScreenRecordingEvent& event) { + if (holder->revoked()) return; + native_screen_recording_event_t c_event = {}; + if (!to_c_screen_recording_event(event, &c_event)) { + return; + } + callback(&c_event, holder->get()); + free_c_screen_recording_event(&c_event); + })); + } catch (...) { + return 0; + } +} + +native_listener_id_t native_screen_recorder_add_listener_async(native_screen_recorder_t screen_recorder, native_screen_recording_event_callback_t_async callback, void* user_data, native_release_user_data_t release_user_data) { + auto holder = nativeapi::capi::UserData::Make(user_data, release_user_data); + if (!callback) return 0; + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return 0; + } + try { + auto registration = std::make_shared(holder); + return static_cast(nativeapi::detail::EventListenerDispatch::AddListener(*self, + [callback, registration](const nativeapi::ScreenRecordingEvent& event) { + if (registration->context->holder->revoked()) return; + std::shared_ptr request; + auto vote = request && request->IsCancelable() ? request->Defer() : nullptr; + native_event_delivery_t delivery = 0; + try { + auto payload = std::shared_ptr(new native_screen_recording_event_t{}, [](native_screen_recording_event_t* value) { free_c_screen_recording_event(value); delete value; }); + if (!to_c_screen_recording_event(event, payload.get())) { if (request) request->Cancel(); return; } + auto* event_pointer = payload.get(); + auto lease = std::make_shared(registration->context, std::move(payload), std::move(vote)); + delivery = nativeapi::HandleTable::GetInstance().Insert(lease); + callback(event_pointer, delivery, registration->context->holder->get()); + } catch (...) { + if (request) request->Cancel(); + if (delivery) native_event_delivery_complete(delivery, false); + } + }, registration->context->active)); + } catch (...) { + return 0; + } +} + +bool native_screen_recorder_remove_listener(native_screen_recorder_t screen_recorder, native_listener_id_t listener_id) { + auto self = nativeapi::HandleTable::GetInstance().Resolve(screen_recorder); + if (!self) { + return false; + } + try { + return self->RemoveListener(static_cast(listener_id)); + } catch (...) { + return false; + } +} + +bool to_c_screen_recording_event(const nativeapi::ScreenRecordingEvent& event, native_screen_recording_event_t* out) { + if (!out) { + return false; + } + *out = native_screen_recording_event_t{}; + if (const auto* typed = dynamic_cast(&event)) { + out->type = NATIVE_SCREEN_RECORDING_EVENT_TYPE_STARTED; + (void)typed; + return true; + } + if (const auto* typed = dynamic_cast(&event)) { + out->type = NATIVE_SCREEN_RECORDING_EVENT_TYPE_COMPLETED; + out->data.completed.file_path = to_c_str(typed->GetFilePath()); + out->data.completed.duration = typed->GetDuration(); + return true; + } + if (const auto* typed = dynamic_cast(&event)) { + out->type = NATIVE_SCREEN_RECORDING_EVENT_TYPE_CANCELLED; + (void)typed; + return true; + } + if (const auto* typed = dynamic_cast(&event)) { + out->type = NATIVE_SCREEN_RECORDING_EVENT_TYPE_FAILED; + out->data.failed.message = to_c_str(typed->GetMessage()); + return true; + } + return false; +} + +void free_c_screen_recording_event(native_screen_recording_event_t* value) { + if (!value) { + return; + } + if (value->type == NATIVE_SCREEN_RECORDING_EVENT_TYPE_COMPLETED) { + free_c_str(value->data.completed.file_path); + value->data.completed.file_path = nullptr; + } + if (value->type == NATIVE_SCREEN_RECORDING_EVENT_TYPE_FAILED) { + free_c_str(value->data.failed.message); + value->data.failed.message = nullptr; + } +} + diff --git a/src/capi/screen_recorder_c.h b/src/capi/screen_recorder_c.h new file mode 100644 index 00000000..6342366c --- /dev/null +++ b/src/capi/screen_recorder_c.h @@ -0,0 +1,220 @@ +// AUTO-GENERATED. DO NOT EDIT. +// Any manual changes WILL BE LOST when this file is regenerated. + +#pragma once + +#include +#include + +#include "common_c.h" +typedef uint64_t native_capture_source_t; + +#include "capture_source_c.h" +#include "screen_capturer_c.h" + +#if _WIN32 +#define FFI_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FFI_PLUGIN_EXPORT +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + NATIVE_SCREEN_RECORDING_STATE_IDLE = 0, + NATIVE_SCREEN_RECORDING_STATE_STARTING = 1, + NATIVE_SCREEN_RECORDING_STATE_RECORDING = 2, + NATIVE_SCREEN_RECORDING_STATE_STOPPING = 3, + NATIVE_SCREEN_RECORDING_STATE_COMPLETED = 4, + NATIVE_SCREEN_RECORDING_STATE_CANCELLED = 5, + NATIVE_SCREEN_RECORDING_STATE_FAILED = 6, +} native_screen_recording_state_t; + +typedef struct { + native_screen_capture_options_t capture; + double frame_rate; + unsigned long video_bitrate; +} native_screen_recording_options_t; + +/// Opaque ScreenRecorder handle. +/// +/// A generational index into the library's handle table, NOT a pointer: +/// never dereference it, and compare it against NATIVE_INVALID_SCREEN_RECORDER rather than NULL. +/// Releasing a handle invalidates it; later calls fail safely instead of +/// touching freed memory. +typedef uint64_t native_screen_recorder_t; + +/// Never refers to a live ScreenRecorder. +#define NATIVE_INVALID_SCREEN_RECORDER ((native_screen_recorder_t)0) + +/// Which concrete ScreenRecordingEvent arrived. +typedef enum { + NATIVE_SCREEN_RECORDING_EVENT_TYPE_STARTED = 0, + NATIVE_SCREEN_RECORDING_EVENT_TYPE_COMPLETED = 1, + NATIVE_SCREEN_RECORDING_EVENT_TYPE_CANCELLED = 2, + NATIVE_SCREEN_RECORDING_EVENT_TYPE_FAILED = 3, +} native_screen_recording_event_type_t; + +/// One ScreenRecordingEvent, tagged by its concrete type. +/// +/// Synchronous callbacks borrow this payload until they return. Async callbacks +/// borrow it until event_delivery_complete. Copy anything needed after that. +typedef struct { + native_screen_recording_event_type_t type; + union { + struct { + char* file_path; + double duration; + } completed; + struct { + char* message; + } failed; + } data; +} native_screen_recording_event_t; + +typedef void (*native_screen_recording_event_callback_t)(const native_screen_recording_event_t* event, void* user_data); +typedef void (*native_screen_recording_event_callback_t_async)(const native_screen_recording_event_t* event, native_event_delivery_t delivery, void* user_data); + +/// Frees everything the struct owns. +FFI_PLUGIN_EXPORT +void native_screen_recording_options_free(native_screen_recording_options_t* value); + +/// Creates a ScreenRecorder instance; release it with native_screen_recorder_free(). +FFI_PLUGIN_EXPORT +native_screen_recorder_t native_screen_recorder_create(void); + +FFI_PLUGIN_EXPORT +bool native_screen_recorder_is_supported(void); + +FFI_PLUGIN_EXPORT +bool native_screen_recorder_start(native_screen_recorder_t screen_recorder, native_capture_source_t source, const char* file_path, native_screen_recording_options_t options); + +FFI_PLUGIN_EXPORT +bool native_screen_recorder_stop(native_screen_recorder_t screen_recorder); + +FFI_PLUGIN_EXPORT +bool native_screen_recorder_cancel(native_screen_recorder_t screen_recorder); + +FFI_PLUGIN_EXPORT +native_screen_recording_state_t native_screen_recorder_get_state(native_screen_recorder_t screen_recorder); + +FFI_PLUGIN_EXPORT +bool native_screen_recorder_is_active(native_screen_recorder_t screen_recorder); + +FFI_PLUGIN_EXPORT +double native_screen_recorder_get_duration(native_screen_recorder_t screen_recorder); + +/// Caller owns the returned string; free it with free_c_str(). +FFI_PLUGIN_EXPORT +char* native_screen_recorder_get_file_path(native_screen_recorder_t screen_recorder); + +/// Releases the caller's reference. Safe to call with an invalid or +/// already-released handle. +FFI_PLUGIN_EXPORT +void native_screen_recorder_free(native_screen_recorder_t screen_recorder); + +/// Registers @p callback for every ScreenRecordingEvent this ScreenRecorder emits. +/// @return the listener id, or NATIVE_INVALID_LISTENER_ID on failure. +FFI_PLUGIN_EXPORT +native_listener_id_t native_screen_recorder_add_listener(native_screen_recorder_t screen_recorder, native_screen_recording_event_callback_t callback, void* user_data, native_release_user_data_t release_user_data); + +/// Registers an asynchronous callback. Its event, borrowed handles and user_data +/// remain valid until event_delivery_complete is called, including after removal. +/// Every delivered payload must be acknowledged. Check is_active before invoking a queued callback. +FFI_PLUGIN_EXPORT +native_listener_id_t native_screen_recorder_add_listener_async(native_screen_recorder_t screen_recorder, native_screen_recording_event_callback_t_async callback, void* user_data, native_release_user_data_t release_user_data); + +/// Unregisters a listener. Returns false if unknown. +FFI_PLUGIN_EXPORT +bool native_screen_recorder_remove_listener(native_screen_recorder_t screen_recorder, native_listener_id_t listener_id); + +#ifdef __cplusplus +} +#endif + +#ifdef __cplusplus +namespace nativeapi { +class ScreenRecordingEvent; +} // namespace nativeapi + +/// Fills @p out from @p event. Returns false when the event is not one +/// of the concrete types the C ABI knows about. +bool to_c_screen_recording_event(const nativeapi::ScreenRecordingEvent& event, native_screen_recording_event_t* out); +/// Releases everything to_c_screen_recording_event() allocated. +void free_c_screen_recording_event(native_screen_recording_event_t* value); + +#endif + +#ifdef __cplusplus +#include "../screen_recorder.h" +#include "string_utils_c.h" +#include "user_data.h" + +// Conversion helpers between these C types and their C++ originals. + +inline native_screen_recording_state_t to_c_screen_recording_state(nativeapi::ScreenRecordingState value); +inline nativeapi::ScreenRecordingState to_cpp_screen_recording_state(native_screen_recording_state_t value); +inline native_screen_recording_options_t to_c_screen_recording_options(const nativeapi::ScreenRecordingOptions& value); +inline nativeapi::ScreenRecordingOptions to_cpp_screen_recording_options(const native_screen_recording_options_t& value); + +inline native_screen_recording_state_t to_c_screen_recording_state(nativeapi::ScreenRecordingState value) { + switch (value) { + case nativeapi::ScreenRecordingState::Idle: + return NATIVE_SCREEN_RECORDING_STATE_IDLE; + case nativeapi::ScreenRecordingState::Starting: + return NATIVE_SCREEN_RECORDING_STATE_STARTING; + case nativeapi::ScreenRecordingState::Recording: + return NATIVE_SCREEN_RECORDING_STATE_RECORDING; + case nativeapi::ScreenRecordingState::Stopping: + return NATIVE_SCREEN_RECORDING_STATE_STOPPING; + case nativeapi::ScreenRecordingState::Completed: + return NATIVE_SCREEN_RECORDING_STATE_COMPLETED; + case nativeapi::ScreenRecordingState::Cancelled: + return NATIVE_SCREEN_RECORDING_STATE_CANCELLED; + case nativeapi::ScreenRecordingState::Failed: + return NATIVE_SCREEN_RECORDING_STATE_FAILED; + default: + return NATIVE_SCREEN_RECORDING_STATE_IDLE; + } +} + +inline nativeapi::ScreenRecordingState to_cpp_screen_recording_state(native_screen_recording_state_t value) { + switch (value) { + case NATIVE_SCREEN_RECORDING_STATE_IDLE: + return nativeapi::ScreenRecordingState::Idle; + case NATIVE_SCREEN_RECORDING_STATE_STARTING: + return nativeapi::ScreenRecordingState::Starting; + case NATIVE_SCREEN_RECORDING_STATE_RECORDING: + return nativeapi::ScreenRecordingState::Recording; + case NATIVE_SCREEN_RECORDING_STATE_STOPPING: + return nativeapi::ScreenRecordingState::Stopping; + case NATIVE_SCREEN_RECORDING_STATE_COMPLETED: + return nativeapi::ScreenRecordingState::Completed; + case NATIVE_SCREEN_RECORDING_STATE_CANCELLED: + return nativeapi::ScreenRecordingState::Cancelled; + case NATIVE_SCREEN_RECORDING_STATE_FAILED: + return nativeapi::ScreenRecordingState::Failed; + default: + return static_cast(value); + } +} + +inline native_screen_recording_options_t to_c_screen_recording_options(const nativeapi::ScreenRecordingOptions& value) { + native_screen_recording_options_t result = {}; + result.capture = to_c_screen_capture_options(value.capture); + result.frame_rate = value.frame_rate; + result.video_bitrate = value.video_bitrate; + return result; +} + +inline nativeapi::ScreenRecordingOptions to_cpp_screen_recording_options(const native_screen_recording_options_t& value) { + nativeapi::ScreenRecordingOptions result = {}; + result.capture = to_cpp_screen_capture_options(value.capture); + result.frame_rate = value.frame_rate; + result.video_bitrate = value.video_bitrate; + return result; +} + +#endif // __cplusplus diff --git a/src/capture_source.cpp b/src/capture_source.cpp new file mode 100644 index 00000000..172c198d --- /dev/null +++ b/src/capture_source.cpp @@ -0,0 +1,50 @@ +#include "capture_source.h" + +#include +#include "screen_capture_backend.h" + +namespace nativeapi { +class CaptureSource::Impl { + public: + std::shared_ptr target; + std::atomic busy{false}; +}; + +CaptureSource::CaptureSource() : pimpl_(std::make_unique()) {} +CaptureSource::~CaptureSource() = default; +std::shared_ptr CaptureSource::FromDisplay(std::shared_ptr display) { + try { return detail::CaptureAccess::Wrap(detail::CaptureDisplayTarget(std::move(display))); } + catch (...) { return nullptr; } +} +std::shared_ptr CaptureSource::FromWindow(std::shared_ptr window) { + try { return detail::CaptureAccess::Wrap(detail::CaptureWindowTarget(std::move(window))); } + catch (...) { return nullptr; } +} +CaptureSourceType CaptureSource::GetType() const { + try { return pimpl_->target ? pimpl_->target->Type() : CaptureSourceType::None; } + catch (...) { return CaptureSourceType::None; } +} +std::string CaptureSource::GetName() const { + try { return pimpl_->target ? pimpl_->target->Name() : std::string(); } + catch (...) { return {}; } +} + +namespace detail { +std::shared_ptr CaptureAccess::Wrap(std::shared_ptr target) { + if (!target) return nullptr; + auto result = std::shared_ptr(new CaptureSource()); + result->pimpl_->target = std::move(target); + return result; +} +std::shared_ptr CaptureAccess::Acquire(const std::shared_ptr& source) { + if (!source || !source->pimpl_->target || source->pimpl_->busy.exchange(true)) return nullptr; + return source->pimpl_->target; +} +void CaptureAccess::Release(const std::shared_ptr& source) { + if (source) source->pimpl_->busy.store(false); +} +CaptureLease::CaptureLease(std::shared_ptr source) + : target(CaptureAccess::Acquire(source)), source_(target ? std::move(source) : nullptr) {} +CaptureLease::~CaptureLease() { CaptureAccess::Release(source_); } +} // namespace detail +} // namespace nativeapi diff --git a/src/capture_source.h b/src/capture_source.h new file mode 100644 index 00000000..8901be1f --- /dev/null +++ b/src/capture_source.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +namespace nativeapi { +class Display; +class Window; +namespace detail { struct CaptureAccess; } + +/** @brief The kind of content bound to a capture source. */ +enum class CaptureSourceType { + /** No live target. */ + None, + /** One display. */ + Display, + /** One application window. */ + Window, + /** A valid system-selected source whose kind is not disclosed by the platform. */ + Unknown, +}; + +/** + * @brief A capture target and its optional system authorization session. + * + * Sources are shared identity objects, not persistent permission tokens. A source + * accepts only one in-flight screenshot or recording. Destroying the last reference + * releases its authorization resources. A disappearing target is never replaced. + * Factories only describe targets; permission may be requested during capture. + * Obtain other applications' windows through ScreenCapturer::SelectSource(). + * + * @note Platform availability: + * - macOS: ⚠️ ScreenCaptureKit on macOS 14+; system permission required + * - Windows: ⚠️ Windows Graphics Capture on Windows 10 2004+; desktop session required + * - Linux: ❌ Not implemented - Factories return nullptr + * - Android: ❌ Not implemented - Factories return nullptr + * - iOS: ❌ Not implemented - Factories return nullptr + * - OpenHarmony: ❌ Not implemented - Factories return nullptr + * + * @code + * auto source = CaptureSource::FromDisplay(DisplayManager::GetInstance().GetPrimary()); + * @endcode + */ +class CaptureSource { + public: + /** @brief Describes a display without prompting or starting capture. + * @param display Target display. + * @return nullptr for null, unavailable or unsupported targets. */ + static std::shared_ptr FromDisplay(std::shared_ptr display); + /** @brief Describes a window without prompting or starting capture. + * @param window Target window. + * @return nullptr for null, unavailable or unsupported targets. */ + static std::shared_ptr FromWindow(std::shared_ptr window); + + ~CaptureSource(); + CaptureSource(const CaptureSource&) = delete; + CaptureSource& operator=(const CaptureSource&) = delete; + CaptureSource(CaptureSource&&) = delete; + CaptureSource& operator=(CaptureSource&&) = delete; + + /** @brief Gets the target kind, or None when known to be unavailable. */ + CaptureSourceType GetType() const; + /** @brief Gets the current display name, or an empty string when unavailable. */ + std::string GetName() const; + + private: + CaptureSource(); + friend struct detail::CaptureAccess; + class Impl; + std::unique_ptr pimpl_; +}; +} // namespace nativeapi diff --git a/src/foundation/id_allocator.h b/src/foundation/id_allocator.h index 4ea676f2..974879fa 100644 --- a/src/foundation/id_allocator.h +++ b/src/foundation/id_allocator.h @@ -85,6 +85,8 @@ class TextField; class ImageView; class EventRequest; class EventDecision; +class CaptureSource; +class ScreenRecorder; namespace capi { class EventDelivery; } // --------------------------------------------------------------------------- @@ -209,6 +211,14 @@ template <> struct IdTypeTag { static constexpr uint32_t value = 27; }; +template <> +struct IdTypeTag { + static constexpr uint32_t value = 28; +}; +template <> +struct IdTypeTag { + static constexpr uint32_t value = 29; +}; /** * Thread-safe ID allocator with type information. diff --git a/src/platform/android/screen_capture_android.cpp b/src/platform/android/screen_capture_android.cpp new file mode 100644 index 00000000..6d3c0960 --- /dev/null +++ b/src/platform/android/screen_capture_android.cpp @@ -0,0 +1,17 @@ +#include "../../screen_capture_backend.h" + +namespace nativeapi::detail { +bool CaptureBackendSupported() { return false; } +bool CapturePickerSupported() { return false; } +bool CaptureEncodingSupported() { return false; } +std::shared_ptr CaptureDisplayTarget(std::shared_ptr) { return nullptr; } +std::shared_ptr CaptureWindowTarget(std::shared_ptr) { return nullptr; } +void SelectCaptureTarget(const CaptureSourceSelectionOptions&, + std::function)> callback) { callback(false, nullptr); } +std::shared_ptr CaptureFrameImage(const CaptureFrame&) { return nullptr; } +std::unique_ptr CreateCaptureEncoder(const std::string&, int, int, + const ScreenRecordingOptions&, std::string& error) { + error = "Screen recording is not supported on this platform"; + return nullptr; +} +} // namespace nativeapi::detail diff --git a/src/platform/ios/screen_capture_ios.mm b/src/platform/ios/screen_capture_ios.mm new file mode 100644 index 00000000..6d3c0960 --- /dev/null +++ b/src/platform/ios/screen_capture_ios.mm @@ -0,0 +1,17 @@ +#include "../../screen_capture_backend.h" + +namespace nativeapi::detail { +bool CaptureBackendSupported() { return false; } +bool CapturePickerSupported() { return false; } +bool CaptureEncodingSupported() { return false; } +std::shared_ptr CaptureDisplayTarget(std::shared_ptr) { return nullptr; } +std::shared_ptr CaptureWindowTarget(std::shared_ptr) { return nullptr; } +void SelectCaptureTarget(const CaptureSourceSelectionOptions&, + std::function)> callback) { callback(false, nullptr); } +std::shared_ptr CaptureFrameImage(const CaptureFrame&) { return nullptr; } +std::unique_ptr CreateCaptureEncoder(const std::string&, int, int, + const ScreenRecordingOptions&, std::string& error) { + error = "Screen recording is not supported on this platform"; + return nullptr; +} +} // namespace nativeapi::detail diff --git a/src/platform/linux/screen_capture_linux.cpp b/src/platform/linux/screen_capture_linux.cpp new file mode 100644 index 00000000..6d3c0960 --- /dev/null +++ b/src/platform/linux/screen_capture_linux.cpp @@ -0,0 +1,17 @@ +#include "../../screen_capture_backend.h" + +namespace nativeapi::detail { +bool CaptureBackendSupported() { return false; } +bool CapturePickerSupported() { return false; } +bool CaptureEncodingSupported() { return false; } +std::shared_ptr CaptureDisplayTarget(std::shared_ptr) { return nullptr; } +std::shared_ptr CaptureWindowTarget(std::shared_ptr) { return nullptr; } +void SelectCaptureTarget(const CaptureSourceSelectionOptions&, + std::function)> callback) { callback(false, nullptr); } +std::shared_ptr CaptureFrameImage(const CaptureFrame&) { return nullptr; } +std::unique_ptr CreateCaptureEncoder(const std::string&, int, int, + const ScreenRecordingOptions&, std::string& error) { + error = "Screen recording is not supported on this platform"; + return nullptr; +} +} // namespace nativeapi::detail diff --git a/src/platform/macos/screen_capture_macos.mm b/src/platform/macos/screen_capture_macos.mm new file mode 100644 index 00000000..75089e97 --- /dev/null +++ b/src/platform/macos/screen_capture_macos.mm @@ -0,0 +1,528 @@ +#import +#import +#import +#import + +// Static-archive consumers (notably Cargo) do not inherit CMake's link flags. +// Attach this dependency to the capture object itself: unused capture code +// must not make older systems load ScreenCaptureKit. With an older deployment +// target its availability-annotated references make this a weak load command. +asm(".linker_option \"-framework\", \"ScreenCaptureKit\""); + +#include +#include +#include +#include +#include +#include + +#include "../../display.h" +#include "../../foundation/dispatcher.h" +#include "../../image.h" +#include "../../screen_capture_backend.h" +#include "../../window.h" +#include "string_utils_macos.h" + +namespace nativeapi::detail { +namespace { +// Works in both ARC consumers and the core's ordinary Objective-C++ build. +template class Obj { + public: + Obj(T value = nil) : value_(value) { +#if !__has_feature(objc_arc) + [value_ retain]; +#endif + } + Obj(const Obj& other) : Obj(other.get()) {} + Obj& operator=(const Obj& other) { + if (this != &other) { +#if !__has_feature(objc_arc) + [other.value_ retain]; + [value_ release]; +#endif + value_ = other.value_; + } + return *this; + } + ~Obj() { +#if !__has_feature(objc_arc) + [value_ release]; +#endif + } + T get() const { return value_; } + private: + T value_; +}; +template T Autorelease(T value) { +#if !__has_feature(objc_arc) + return [value autorelease]; +#else + return value; +#endif +} + +struct Completion { + std::mutex mutex; + std::condition_variable changed; + bool done = false; + std::string error; + Obj content; + void Complete(NSError* failure) { + std::lock_guard lock(mutex); + error = ToStdString(failure.localizedDescription); + done = true; + changed.notify_all(); + } + bool Wait(const std::atomic& cancel, std::string& failure) { + std::unique_lock lock(mutex); + auto deadline = CaptureClock::now() + std::chrono::seconds(15); + while (!done && !cancel && CaptureClock::now() < deadline) + changed.wait_for(lock, std::chrono::milliseconds(50)); + if (done && !error.empty()) { failure = error; return false; } + if (cancel) { failure.clear(); return false; } + if (!done) { failure = "ScreenCaptureKit operation timed out"; return false; } + failure = error; + return error.empty(); + } +}; + +struct FrameMailbox { + std::mutex mutex; + std::condition_variable changed; + std::shared_ptr latest; + std::string error; + bool stopped = false; + bool resizing = false; + Obj configuration; +}; +} // namespace +} // namespace nativeapi::detail + +@interface NativeAPICaptureOutput : NSObject { + @public + std::shared_ptr mailbox; +} +@end + +@implementation NativeAPICaptureOutput +- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error { + std::lock_guard lock(mailbox->mutex); + if (!mailbox->stopped) { + mailbox->error = nativeapi::ToStdString(error.localizedDescription); + if (mailbox->error.empty()) mailbox->error = "Screen capture stopped unexpectedly"; + } + mailbox->changed.notify_all(); +} +- (void)stream:(SCStream*)stream didOutputSampleBuffer:(CMSampleBufferRef)sample + ofType:(SCStreamOutputType)type { + using namespace nativeapi::detail; + @autoreleasepool { + if (type != SCStreamOutputTypeScreen || !CMSampleBufferIsValid(sample)) return; + try { + NSArray* attachments = (__bridge NSArray*)CMSampleBufferGetSampleAttachmentsArray(sample, false); + NSDictionary* info = attachments.firstObject; + NSNumber* status = info[SCStreamFrameInfoStatus]; + if (!status || status.integerValue != SCFrameStatusComplete) return; + CVPixelBufferRef pixels = CMSampleBufferGetImageBuffer(sample); + if (!pixels || CVPixelBufferGetPixelFormatType(pixels) != kCVPixelFormatType_32BGRA) return; + int width = int(CVPixelBufferGetWidth(pixels)), height = int(CVPixelBufferGetHeight(pixels)); + if (width <= 0 || height <= 0 || width > 32768 || height > 32768) return; + { + std::lock_guard lock(mailbox->mutex); + if (mailbox->stopped || mailbox->resizing) return; + } + // Keep capture at source resolution after a window/display size change. + CGRect screen = CGRectZero; + NSDictionary* screenValue = info[SCStreamFrameInfoScreenRect]; + double scale = [info[SCStreamFrameInfoScaleFactor] doubleValue]; + if (screenValue && CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)screenValue, &screen) + && scale > 0) { + int next_width = int(std::ceil(screen.size.width * scale)); + int next_height = int(std::ceil(screen.size.height * scale)); + if (next_width > 0 && next_height > 0 && next_width <= 32768 && next_height <= 32768 && + (next_width != width || next_height != height)) { + auto state = mailbox; + { std::lock_guard lock(state->mutex); state->resizing = true; } + SCStreamConfiguration* config = state->configuration.get(); + config.width = next_width; + config.height = next_height; + [stream updateConfiguration:config completionHandler:^(NSError* error) { + std::lock_guard lock(state->mutex); + state->resizing = false; + if (error) state->error = nativeapi::ToStdString(error.localizedDescription); + state->changed.notify_all(); + }]; + return; + } + } + auto frame = std::make_shared(); + frame->width = width; + frame->height = height; + frame->time = CaptureClock::now(); + frame->pixels.resize(size_t(width) * height * 4); + if (CVPixelBufferLockBaseAddress(pixels, kCVPixelBufferLock_ReadOnly) != kCVReturnSuccess) return; + auto bytes = static_cast(CVPixelBufferGetBaseAddress(pixels)); + size_t stride = CVPixelBufferGetBytesPerRow(pixels); + for (int y = 0; y < height; ++y) + std::memcpy(frame->pixels.data() + size_t(y) * width * 4, bytes + size_t(y) * stride, size_t(width) * 4); + CVPixelBufferUnlockBaseAddress(pixels, kCVPixelBufferLock_ReadOnly); + std::lock_guard lock(mailbox->mutex); + if (!mailbox->stopped) mailbox->latest = std::move(frame); + mailbox->changed.notify_all(); + } catch (...) { + std::lock_guard lock(mailbox->mutex); + mailbox->error = "Cannot copy the captured frame"; + mailbox->changed.notify_all(); + } + } +} +@end + +namespace nativeapi::detail { +namespace { +class MacStream : public CaptureStream { + public: + explicit MacStream(SCContentFilter* filter, const ScreenCaptureOptions& options, double fps) + : mailbox_(std::make_shared()) { + @autoreleasepool { + auto config = Autorelease([[SCStreamConfiguration alloc] init]); + config.width = size_t(std::ceil(filter.contentRect.size.width * filter.pointPixelScale)); + config.height = size_t(std::ceil(filter.contentRect.size.height * filter.pointPixelScale)); + config.pixelFormat = kCVPixelFormatType_32BGRA; + config.colorSpaceName = kCGColorSpaceSRGB; + config.showsCursor = options.include_cursor; + config.ignoreShadowsSingleWindow = YES; + config.queueDepth = 3; + config.minimumFrameInterval = CMTimeMakeWithSeconds(1.0 / fps, 1000000); + if (@available(macOS 15.0, *)) config.captureDynamicRange = SCCaptureDynamicRangeSDR; + mailbox_->configuration = Obj(config); + output_ = Obj(Autorelease([[NativeAPICaptureOutput alloc] init])); + output_.get()->mailbox = mailbox_; + stream_ = Obj(Autorelease([[SCStream alloc] initWithFilter:filter + configuration:config delegate:output_.get()])); + queue_ = dispatch_queue_create("org.libnativeapi.screen-capture", DISPATCH_QUEUE_SERIAL); + } + } + bool Start(const std::atomic& cancel, std::string& error) { + @autoreleasepool { + NSError* failure = nil; + if (![stream_.get() addStreamOutput:output_.get() type:SCStreamOutputTypeScreen + sampleHandlerQueue:queue_ error:&failure]) { + error = ToStdString(failure.localizedDescription); return false; + } + auto completion = std::make_shared(); + [stream_.get() startCaptureWithCompletionHandler:^(NSError* failure) { completion->Complete(failure); }]; + return completion->Wait(cancel, error); + } + } + std::shared_ptr NextFrame(int timeout, std::string& error) override { + std::unique_lock lock(mailbox_->mutex); + mailbox_->changed.wait_for(lock, std::chrono::milliseconds(timeout), [&] { + return mailbox_->latest || !mailbox_->error.empty() || mailbox_->stopped; + }); + error = mailbox_->error; + return error.empty() ? std::exchange(mailbox_->latest, nullptr) : nullptr; + } + bool Stop(std::string& error) override { + @autoreleasepool { + { std::lock_guard lock(mailbox_->mutex); + if (mailbox_->stopped) return true; + mailbox_->stopped = true; + mailbox_->latest.reset(); + } + auto completion = std::make_shared(); + [stream_.get() stopCaptureWithCompletionHandler:^(NSError* failure) { completion->Complete(failure); }]; + std::atomic never_cancel{false}; + return completion->Wait(never_cancel, error); + } + } + ~MacStream() override { + std::string ignored; + Stop(ignored); +#if !OS_OBJECT_USE_OBJC + if (queue_) dispatch_release(queue_); +#endif + } + private: + std::shared_ptr mailbox_; + Obj output_; + Obj stream_; + dispatch_queue_t queue_ = nullptr; +}; + +class MacTarget : public CaptureTarget { + public: + CaptureSourceType kind = CaptureSourceType::None; + uint32_t identity = 0; + std::shared_ptr display; + std::shared_ptr window; + Obj selected; + std::shared_ptr picker_lifetime; + CaptureSourceType Type() const override { + if (display && !CGDisplayIsActive(identity)) return CaptureSourceType::None; + if (window && !window->GetNativeObject()) return CaptureSourceType::None; + return kind; + } + std::string Name() const override { + if (Type() == CaptureSourceType::None) return {}; + if (display) return display->GetName(); + if (window) return window->GetTitle(); + if (@available(macOS 15.2, *)) { + if (kind == CaptureSourceType::Window) + return ToStdString(selected.get().includedWindows.firstObject.title); + } + return {}; + } + std::unique_ptr Open(const ScreenCaptureOptions& options, double fps, + const std::atomic& cancel, std::string& error) override { + @autoreleasepool { + Obj filter = selected; + if (!filter.get()) { + auto completion = std::make_shared(); + [SCShareableContent getShareableContentExcludingDesktopWindows:NO onScreenWindowsOnly:NO + completionHandler:^(SCShareableContent* content, NSError* failure) { + completion->content = Obj(content); + completion->Complete(failure); + }]; + if (!completion->Wait(cancel, error)) return nullptr; + if (kind == CaptureSourceType::Display) { + for (SCDisplay* item in completion->content.get().displays) + if (item.displayID == identity) filter = Obj(Autorelease( + [[SCContentFilter alloc] initWithDisplay:item excludingWindows:@[]])); + } else { + for (SCWindow* item in completion->content.get().windows) + if (item.windowID == identity) filter = Obj(Autorelease( + [[SCContentFilter alloc] initWithDesktopIndependentWindow:item])); + } + } + if (!filter.get()) { error = "The capture target no longer exists"; return nullptr; } + auto stream = std::make_unique(filter.get(), options, fps); + if (!stream->Start(cancel, error)) return nullptr; + return stream; + } + } +}; + +class MacEncoder : public CaptureEncoder { + public: + Obj writer; + Obj input; + Obj adaptor; + std::shared_ptr last; + double last_time = -1; + bool finished = false; + bool Append(const CaptureFrame& frame, double seconds, std::string& error) override { + @autoreleasepool { + auto deadline = CaptureClock::now() + std::chrono::seconds(10); + while (!input.get().readyForMoreMediaData && writer.get().status == AVAssetWriterStatusWriting && + CaptureClock::now() < deadline) std::this_thread::sleep_for(std::chrono::milliseconds(2)); + if (!input.get().readyForMoreMediaData) { + error = "Video encoder stalled: " + ToStdString(writer.get().error.localizedDescription); return false; + } + CVPixelBufferRef buffer = nullptr; + if (CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, adaptor.get().pixelBufferPool, + &buffer) != kCVReturnSuccess) { error = "Cannot allocate encoder pixels"; return false; } + CVPixelBufferLockBaseAddress(buffer, 0); + auto bytes = static_cast(CVPixelBufferGetBaseAddress(buffer)); + auto stride = CVPixelBufferGetBytesPerRow(buffer); + for (int y = 0; y < frame.height; ++y) + std::memcpy(bytes + size_t(y) * stride, + frame.pixels.data() + size_t(y) * frame.width * 4, size_t(frame.width) * 4); + CVPixelBufferUnlockBaseAddress(buffer, 0); + bool ok = [adaptor.get() appendPixelBuffer:buffer withPresentationTime:CMTimeMakeWithSeconds(seconds, 1000000)]; + CVPixelBufferRelease(buffer); + if (!ok) { error = ToStdString(writer.get().error.localizedDescription); return false; } + last = std::make_shared(frame); + last_time = seconds; + return true; + } + } + bool Finish(double end, std::string& error) override { + @autoreleasepool { + // A trailing sample makes static scenes cover the full requested duration. + if (last && end > last_time + 0.002 && !Append(*last, end - 0.001, error)) return false; + [writer.get() endSessionAtSourceTime:CMTimeMakeWithSeconds(end, 1000000)]; + [input.get() markAsFinished]; + auto completion = std::make_shared(); + Obj retained = writer; + [writer.get() finishWritingWithCompletionHandler:^{ completion->Complete(retained.get().error); }]; + std::atomic never_cancel{false}; + finished = completion->Wait(never_cancel, error) && writer.get().status == AVAssetWriterStatusCompleted; + return finished; + } + } + ~MacEncoder() override { + @autoreleasepool { if (!finished) [writer.get() cancelWriting]; } + } +}; +} // namespace +} // namespace nativeapi::detail + +@interface NativeAPICapturePicker : NSObject { + @public + std::function completion; +} +@end +@implementation NativeAPICapturePicker +- (void)contentSharingPicker:(SCContentSharingPicker*)picker didCancelForStream:(SCStream*)stream { + if (!stream && completion) completion(true, nil); +} +- (void)contentSharingPicker:(SCContentSharingPicker*)picker didUpdateWithFilter:(SCContentFilter*)filter + forStream:(SCStream*)stream { + if (!stream && completion) completion(true, filter); +} +- (void)contentSharingPickerStartDidFailWithError:(NSError*)error { + if (completion) completion(false, nil); +} +@end + +namespace nativeapi::detail { +namespace { +std::atomic picker_users{0}; +bool picker_was_active = false; +struct PickerLifetime { + PickerLifetime() { + if (picker_users.fetch_add(1) == 0) picker_was_active = SCContentSharingPicker.sharedPicker.active; + SCContentSharingPicker.sharedPicker.active = YES; + } + ~PickerLifetime() { + if (picker_users.fetch_sub(1) == 1) RunOnMainThread([] { + if (picker_users.load() == 0) SCContentSharingPicker.sharedPicker.active = picker_was_active; + }); + } +}; +Obj pending_picker; +} // namespace + +bool CaptureBackendSupported() { if (@available(macOS 14.0, *)) return true; return false; } +bool CapturePickerSupported() { return CaptureBackendSupported(); } +bool CaptureEncodingSupported() { + @autoreleasepool { + if (!CaptureBackendSupported()) return false; + CFArrayRef encoders = nullptr; + if (VTCopyVideoEncoderList(nullptr, &encoders) != noErr || !encoders) return false; + bool supported = false; + for (NSDictionary* encoder in (__bridge NSArray*)encoders) + if ([encoder[(__bridge NSString*)kVTVideoEncoderList_CodecType] unsignedIntValue] == kCMVideoCodecType_H264) + supported = true; + CFRelease(encoders); + return supported; + } +} +std::shared_ptr CaptureDisplayTarget(std::shared_ptr display) { + if (!CaptureBackendSupported() || !display) return nullptr; + @autoreleasepool { + NSScreen* screen = (__bridge NSScreen*)display->GetNativeObject(); + uint32_t identity = [screen.deviceDescription[@"NSScreenNumber"] unsignedIntValue]; + if (!identity || !CGDisplayIsActive(identity)) return nullptr; + auto target = std::make_shared(); + target->kind = CaptureSourceType::Display; + target->identity = identity; + target->display = std::move(display); + return target; + } +} +std::shared_ptr CaptureWindowTarget(std::shared_ptr window) { + if (!CaptureBackendSupported() || !window) return nullptr; + @autoreleasepool { + NSWindow* native = (__bridge NSWindow*)window->GetNativeObject(); + if (!native || native.windowNumber <= 0) return nullptr; + auto target = std::make_shared(); + target->kind = CaptureSourceType::Window; + target->identity = uint32_t(native.windowNumber); + target->window = std::move(window); + return target; + } +} +void SelectCaptureTarget(const CaptureSourceSelectionOptions& options, + std::function)> callback) { + if (!CapturePickerSupported()) { callback(false, nullptr); return; } + @autoreleasepool { + auto lifetime = std::make_shared(); + SCContentSharingPicker* picker = SCContentSharingPicker.sharedPicker; + Obj old(picker.defaultConfiguration); + auto configuration = Autorelease([[SCContentSharingPickerConfiguration alloc] init]); + configuration.allowedPickerModes = (options.allow_displays ? SCContentSharingPickerModeSingleDisplay : 0) | + (options.allow_windows ? SCContentSharingPickerModeSingleWindow : 0); + configuration.allowsChangingSelectedContent = NO; + picker.defaultConfiguration = configuration; + pending_picker = Obj(Autorelease([[NativeAPICapturePicker alloc] init])); + pending_picker.get()->completion = [callback, lifetime, old](bool ok, SCContentFilter* filter) { + Obj retained(filter); + RunOnMainThread([callback, lifetime, old, ok, retained] { + SCContentSharingPicker* picker = SCContentSharingPicker.sharedPicker; + [picker removeObserver:pending_picker.get()]; + picker.defaultConfiguration = old.get(); + pending_picker = Obj(); + std::shared_ptr target; + if (retained.get()) { + target = std::make_shared(); + target->kind = retained.get().style == SCShareableContentStyleDisplay ? + CaptureSourceType::Display : CaptureSourceType::Window; + target->selected = retained; + target->picker_lifetime = lifetime; + } + callback(ok, target); + }); + }; + [picker addObserver:pending_picker.get()]; + [picker present]; + } +} +std::shared_ptr CaptureFrameImage(const CaptureFrame& frame) { + @autoreleasepool { + CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + CGContextRef context = CGBitmapContextCreate(const_cast(frame.pixels.data()), + frame.width, frame.height, 8, size_t(frame.width) * 4, space, + kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst); + CGColorSpaceRelease(space); + if (!context) return nullptr; + CGImageRef image = CGBitmapContextCreateImage(context); + CGContextRelease(context); + if (!image) return nullptr; + auto representation = Autorelease([[NSBitmapImageRep alloc] initWithCGImage:image]); + CGImageRelease(image); + NSData* png = [representation representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + return png ? Image::FromBase64(ToStdString([png base64EncodedStringWithOptions:0])) : nullptr; + } +} +std::unique_ptr CreateCaptureEncoder(const std::string& path, int width, int height, + const ScreenRecordingOptions& options, std::string& error) { + @autoreleasepool { + auto encoder = std::make_unique(); + NSError* failure = nil; + encoder->writer = Obj(Autorelease([[AVAssetWriter alloc] + initWithURL:[NSURL fileURLWithPath:ToNSString(path)] fileType:AVFileTypeMPEG4 error:&failure])); + if (!encoder->writer.get()) { error = ToStdString(failure.localizedDescription); return nullptr; } + NSMutableDictionary* compression = [NSMutableDictionary dictionary]; + if (options.video_bitrate) compression[AVVideoAverageBitRateKey] = @(options.video_bitrate); + compression[AVVideoExpectedSourceFrameRateKey] = @(options.frame_rate); + NSDictionary* settings = @{ AVVideoCodecKey: AVVideoCodecTypeH264, AVVideoWidthKey: @(width), + AVVideoHeightKey: @(height), AVVideoCompressionPropertiesKey: compression, + AVVideoColorPropertiesKey: @{ AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, + AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, + AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2 } }; + if (![encoder->writer.get() canApplyOutputSettings:settings forMediaType:AVMediaTypeVideo]) { + error = "H.264 encoding settings are unsupported"; return nullptr; + } + encoder->input = Obj([AVAssetWriterInput assetWriterInputWithMediaType: + AVMediaTypeVideo outputSettings:settings]); + encoder->input.get().expectsMediaDataInRealTime = YES; + encoder->adaptor = Obj( + [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput: + encoder->input.get() sourcePixelBufferAttributes:@{ + (id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA), + (id)kCVPixelBufferWidthKey: @(width), (id)kCVPixelBufferHeightKey: @(height), + (id)kCVPixelBufferIOSurfacePropertiesKey: @{} }]); + if (![encoder->writer.get() canAddInput:encoder->input.get()]) { + error = "Cannot attach video encoder"; return nullptr; + } + [encoder->writer.get() addInput:encoder->input.get()]; + if (![encoder->writer.get() startWriting]) { + error = ToStdString(encoder->writer.get().error.localizedDescription); return nullptr; + } + [encoder->writer.get() startSessionAtSourceTime:kCMTimeZero]; + return encoder; + } +} +} // namespace nativeapi::detail diff --git a/src/platform/ohos/screen_capture_ohos.cpp b/src/platform/ohos/screen_capture_ohos.cpp new file mode 100644 index 00000000..6d3c0960 --- /dev/null +++ b/src/platform/ohos/screen_capture_ohos.cpp @@ -0,0 +1,17 @@ +#include "../../screen_capture_backend.h" + +namespace nativeapi::detail { +bool CaptureBackendSupported() { return false; } +bool CapturePickerSupported() { return false; } +bool CaptureEncodingSupported() { return false; } +std::shared_ptr CaptureDisplayTarget(std::shared_ptr) { return nullptr; } +std::shared_ptr CaptureWindowTarget(std::shared_ptr) { return nullptr; } +void SelectCaptureTarget(const CaptureSourceSelectionOptions&, + std::function)> callback) { callback(false, nullptr); } +std::shared_ptr CaptureFrameImage(const CaptureFrame&) { return nullptr; } +std::unique_ptr CreateCaptureEncoder(const std::string&, int, int, + const ScreenRecordingOptions&, std::string& error) { + error = "Screen recording is not supported on this platform"; + return nullptr; +} +} // namespace nativeapi::detail diff --git a/src/platform/windows/screen_capture_windows.cpp b/src/platform/windows/screen_capture_windows.cpp new file mode 100644 index 00000000..aa25fb92 --- /dev/null +++ b/src/platform/windows/screen_capture_windows.cpp @@ -0,0 +1,473 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "../../display.h" +#include "../../foundation/dispatcher.h" +#include "../../image.h" +#include "../../screen_capture_backend.h" +#include "../../window.h" + +namespace nativeapi::detail { +namespace { +using namespace winrt; +using namespace winrt::Windows::Graphics::Capture; +using namespace winrt::Windows::Graphics::DirectX; +using namespace winrt::Windows::Graphics::DirectX::Direct3D11; +using winrt::Windows::Foundation::Metadata::ApiInformation; + +struct Apartment { + HRESULT result = RoInitialize(RO_INIT_MULTITHREADED); + ~Apartment() { if (SUCCEEDED(result)) RoUninitialize(); } +}; +struct MediaFoundation { + HRESULT result = MFStartup(MF_VERSION, MFSTARTUP_FULL); + ~MediaFoundation() { if (SUCCEEDED(result)) MFShutdown(); } +}; +std::string Failure(const hresult_error& error) { return to_string(error.message()); } + +float Half(uint16_t value) { + uint32_t sign = uint32_t(value & 0x8000) << 16; + int exponent = (value >> 10) & 31; + uint32_t fraction = value & 1023; + uint32_t bits; + if (!exponent) { + if (!fraction) bits = sign; + else { + exponent = -14; + while (!(fraction & 1024)) { fraction <<= 1; --exponent; } + bits = sign | (uint32_t(exponent + 127) << 23) | ((fraction & 1023) << 13); + } + } else bits = sign | (uint32_t(exponent == 31 ? 255 : exponent + 112) << 23) | (fraction << 13); + float result; + std::memcpy(&result, &bits, sizeof(result)); + return std::isfinite(result) ? std::max(0.0f, result) : 0.0f; +} +uint8_t Srgb(float value) { + value = value <= 0.0031308f ? 12.92f * value : 1.055f * std::pow(value, 1.0f / 2.4f) - 0.055f; + return uint8_t(std::lround(std::clamp(value, 0.0f, 1.0f) * 255)); +} + +class WindowsStream : public CaptureStream { + public: + explicit WindowsStream(GraphicsCaptureItem item, std::shared_ptr> closed) + : item_(std::move(item)), closed_(std::move(closed)) {} + bool Start(const ScreenCaptureOptions& options, std::string& error) { + try { + include_cursor_ = options.include_cursor; + D3D_FEATURE_LEVEL level; + HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION, + device_.put(), &level, context_.put()); + if (FAILED(hr)) check_hresult(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION, + device_.put(), &level, context_.put())); + auto dxgi = device_.as(); + com_ptr inspectable; + check_hresult(CreateDirect3D11DeviceFromDXGIDevice(dxgi.get(), inspectable.put())); + native_device_ = inspectable.as(); + size_ = item_.Size(); + if (size_.Width <= 0 || size_.Height <= 0) { error = "Capture target has no image"; return false; } + pool_ = Direct3D11CaptureFramePool::CreateFreeThreaded(native_device_, + DirectXPixelFormat::R16G16B16A16Float, 2, size_); + session_ = pool_.CreateCaptureSession(item_); + session_.IsCursorCaptureEnabled(options.include_cursor); + session_.StartCapture(); + return true; + } catch (const hresult_error& e) { error = Failure(e); return false; } + } + std::shared_ptr NextFrame(int timeout, std::string& error) override { + try { + auto deadline = CaptureClock::now() + std::chrono::milliseconds(timeout); + do { + if (closed_->load()) { error = "The capture target was closed"; return nullptr; } + auto sample = pool_.TryGetNextFrame(); + if (!sample) { std::this_thread::sleep_for(std::chrono::milliseconds(2)); continue; } + auto size = sample.ContentSize(); + if (size.Width <= 0 || size.Height <= 0 || size.Width > 32768 || size.Height > 32768) { + sample.Close(); error = "Invalid capture dimensions"; return nullptr; + } + auto access = sample.Surface().as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>(); + com_ptr texture; + check_hresult(access->GetInterface(__uuidof(ID3D11Texture2D), texture.put_void())); + D3D11_TEXTURE2D_DESC description; + texture->GetDesc(&description); + bool resize = size.Width != size_.Width || size.Height != size_.Height; + if (UINT(size.Width) > description.Width || UINT(size.Height) > description.Height) { + sample.Close(); + sample = nullptr; + access = nullptr; + texture = nullptr; + if (resize) { + size_ = size; + Resize(); + } + continue; + } + description.Width = size.Width; + description.Height = size.Height; + description.Usage = D3D11_USAGE_STAGING; + description.BindFlags = 0; + description.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + description.MiscFlags = 0; + com_ptr staging; + check_hresult(device_->CreateTexture2D(&description, nullptr, staging.put())); + D3D11_BOX box{0, 0, 0, UINT(size.Width), UINT(size.Height), 1}; + context_->CopySubresourceRegion(staging.get(), 0, 0, 0, 0, texture.get(), 0, &box); + sample.Close(); + sample = nullptr; + access = nullptr; + texture = nullptr; + if (resize) { + size_ = size; + Resize(); + } + auto frame = std::make_shared(); + frame->width = size.Width; + frame->height = size.Height; + frame->time = CaptureClock::now(); + frame->pixels.resize(size_t(size.Width) * size.Height * 4); + D3D11_MAPPED_SUBRESOURCE mapped; + check_hresult(context_->Map(staging.get(), 0, D3D11_MAP_READ, 0, &mapped)); + // WGC float surfaces are linear scRGB. Preserve SDR and tone-map HDR + // highlights with a luminance-based extended Reinhard shoulder. + for (int y = 0; y < size.Height; ++y) { + auto row = reinterpret_cast(static_cast(mapped.pData) + size_t(y) * mapped.RowPitch); + for (int x = 0; x < size.Width; ++x) { + float r = Half(row[x * 4]), g = Half(row[x * 4 + 1]), b = Half(row[x * 4 + 2]); + float peak = std::max({r, g, b}); + if (peak > 1.0f) { + float luminance = 0.2126f * r + 0.7152f * g + 0.0722f * b; + float mapped_luminance = luminance * (1 + luminance / (12.5f * 12.5f)) / (1 + luminance); + float factor = luminance > 0 ? mapped_luminance / luminance : 1; + r *= factor; g *= factor; b *= factor; + } + size_t offset = (size_t(y) * size.Width + x) * 4; + frame->pixels[offset] = Srgb(b); + frame->pixels[offset + 1] = Srgb(g); + frame->pixels[offset + 2] = Srgb(r); + frame->pixels[offset + 3] = 255; + } + } + context_->Unmap(staging.get(), 0); + return frame; + } while (CaptureClock::now() < deadline); + } catch (const hresult_error& e) { error = Failure(e); } + return nullptr; + } + bool Stop(std::string& error) override { + try { + if (session_) { session_.Close(); session_ = nullptr; } + if (pool_) { pool_.Close(); pool_ = nullptr; } + return true; + } catch (const hresult_error& e) { error = Failure(e); return false; } + } + ~WindowsStream() override { std::string ignored; Stop(ignored); } + private: + void Resize() { + // Recreate alone can leave a static target with no new damage/frame after + // the oversized transition frame was discarded. Restart consumption of + // the same authorized item so its complete new image is delivered. + session_.Close(); + session_ = nullptr; + pool_.Close(); + pool_ = Direct3D11CaptureFramePool::CreateFreeThreaded(native_device_, + DirectXPixelFormat::R16G16B16A16Float, 2, size_); + session_ = pool_.CreateCaptureSession(item_); + session_.IsCursorCaptureEnabled(include_cursor_); + session_.StartCapture(); + } + bool include_cursor_ = false; + Apartment apartment_; + GraphicsCaptureItem item_{nullptr}; + std::shared_ptr> closed_; + com_ptr device_; + com_ptr context_; + IDirect3DDevice native_device_{nullptr}; + Direct3D11CaptureFramePool pool_{nullptr}; + GraphicsCaptureSession session_{nullptr}; + winrt::Windows::Graphics::SizeInt32 size_{}; +}; + +class WindowsTarget : public CaptureTarget { + public: + GraphicsCaptureItem item{nullptr}; + CaptureSourceType kind = CaptureSourceType::Unknown; + std::shared_ptr> closed = std::make_shared>(false); + event_token token{}; + std::shared_ptr window; + std::shared_ptr display; + void Observe() { token = item.Closed([alive = closed](auto&&, auto&&) { alive->store(true); }); } + ~WindowsTarget() override { try { if (item) item.Closed(token); } catch (...) {} } + CaptureSourceType Type() const override { return closed->load() ? CaptureSourceType::None : kind; } + std::string Name() const override { + if (closed->load()) return {}; + if (window) return window->GetTitle(); + if (display) return display->GetName(); + return to_string(item.DisplayName()); + } + std::unique_ptr Open(const ScreenCaptureOptions& options, double, + const std::atomic& cancel, std::string& error) override { + if (closed->load()) { error = "Capture source is unavailable"; return nullptr; } + if (cancel) return nullptr; + auto stream = std::make_unique(item, closed); + if (!stream->Start(options, error)) return nullptr; + return stream; + } +}; + +class WindowsEncoder : public CaptureEncoder { + public: + Apartment apartment; + MediaFoundation media; + com_ptr writer; + DWORD index = 0; + double rate = 30; + std::shared_ptr last; + double last_time = -1; + bool Write(const CaptureFrame& frame, double seconds, double duration, std::string& error) { + try { + DWORD length = DWORD(size_t(frame.width) * frame.height * 3 / 2); + com_ptr buffer; + check_hresult(MFCreateMemoryBuffer(length, buffer.put())); + BYTE* bytes = nullptr; + check_hresult(buffer->Lock(&bytes, nullptr, nullptr)); + auto clamp = [](double n) { return BYTE(std::clamp(std::lround(n), 0l, 255l)); }; + for (int y = 0; y < frame.height; ++y) { + for (int x = 0; x < frame.width; ++x) { + const auto* p = frame.pixels.data() + (size_t(y) * frame.width + x) * 4; + bytes[size_t(y) * frame.width + x] = clamp(16 + .182586 * p[2] + .614231 * p[1] + .062007 * p[0]); + } + } + auto uv = bytes + size_t(frame.width) * frame.height; + for (int y = 0; y < frame.height; y += 2) for (int x = 0; x < frame.width; x += 2) { + double r = 0, g = 0, b = 0; + for (int dy = 0; dy < 2; ++dy) for (int dx = 0; dx < 2; ++dx) { + const auto* p = frame.pixels.data() + (size_t(y + dy) * frame.width + x + dx) * 4; + r += p[2] / 4.0; g += p[1] / 4.0; b += p[0] / 4.0; + } + size_t offset = size_t(y / 2) * frame.width + x; + uv[offset] = clamp(128 - .100644 * r - .338572 * g + .439216 * b); + uv[offset + 1] = clamp(128 + .439216 * r - .398942 * g - .040274 * b); + } + check_hresult(buffer->Unlock()); + check_hresult(buffer->SetCurrentLength(length)); + com_ptr sample; + check_hresult(MFCreateSample(sample.put())); + check_hresult(sample->AddBuffer(buffer.get())); + check_hresult(sample->SetSampleTime(LONGLONG(std::llround(seconds * 10000000)))); + check_hresult(sample->SetSampleDuration(std::max(1, LONGLONG(std::llround(duration * 10000000))))); + check_hresult(writer->WriteSample(index, sample.get())); + return true; + } catch (const hresult_error& e) { error = Failure(e); return false; } + } + bool Append(const CaptureFrame& frame, double seconds, std::string& error) override { + // Hold one sample so its duration is the actual next timestamp, not a guessed FPS. + if (last && !Write(*last, last_time, seconds - last_time, error)) return false; + last = std::make_shared(frame); + last_time = seconds; + return true; + } + bool Finish(double end, std::string& error) override { + if (!last) return false; + // Some H.264 MFTs replace the final input sample's duration with one + // nominal frame period. A timestamped tail sample preserves a static + // scene through Stop even with those encoders (without duplicating every + // missing frame). Intermediate durations follow the next timestamp. + double tail = end - 1.0 / rate; + if (tail > last_time + 0.0001) { + if (!Write(*last, last_time, tail - last_time, error) || + !Write(*last, tail, end - tail, error)) return false; + } else if (!Write(*last, last_time, end - last_time, error)) return false; + HRESULT hr = writer->Finalize(); + if (FAILED(hr)) { error = Failure(hresult_error(hr)); return false; } + writer = nullptr; + return true; + } +}; +} // namespace + +bool CaptureBackendSupported() { + try { + Apartment apartment; + return GraphicsCaptureSession::IsSupported() && ApiInformation::IsPropertyPresent( + L"Windows.Graphics.Capture.GraphicsCaptureSession", L"IsCursorCaptureEnabled"); + } catch (...) { return false; } +} +bool CapturePickerSupported() { return CaptureBackendSupported(); } +bool CaptureEncodingSupported() { + Apartment apartment; + MediaFoundation media; + if (FAILED(media.result)) return false; + MFT_REGISTER_TYPE_INFO output{MFMediaType_Video, MFVideoFormat_H264}; + IMFActivate** encoders = nullptr; + UINT32 count = 0; + HRESULT hr = MFTEnumEx(MFT_CATEGORY_VIDEO_ENCODER, MFT_ENUM_FLAG_ALL, nullptr, &output, &encoders, &count); + for (UINT32 i = 0; i < count; ++i) encoders[i]->Release(); + CoTaskMemFree(encoders); + return SUCCEEDED(hr) && count > 0; +} +std::shared_ptr CaptureDisplayTarget(std::shared_ptr display) { + if (!display || !CaptureBackendSupported()) return nullptr; + Apartment apartment; + auto monitor = static_cast(display->GetNativeObject()); + MONITORINFO info{sizeof(info)}; + if (!GetMonitorInfoW(monitor, &info)) return nullptr; + auto target = std::make_shared(); + auto factory = get_activation_factory(); + check_hresult(factory->CreateForMonitor(monitor, guid_of(), put_abi(target->item))); + target->kind = CaptureSourceType::Display; + target->display = std::move(display); + target->Observe(); + return target; +} +std::shared_ptr CaptureWindowTarget(std::shared_ptr window) { + if (!window || !CaptureBackendSupported()) return nullptr; + Apartment apartment; + auto hwnd = static_cast(window->GetNativeObject()); + if (!IsWindow(hwnd)) return nullptr; + auto target = std::make_shared(); + auto factory = get_activation_factory(); + check_hresult(factory->CreateForWindow(hwnd, guid_of(), put_abi(target->item))); + target->kind = CaptureSourceType::Window; + target->window = std::move(window); + target->Observe(); + return target; +} +void SelectCaptureTarget(const CaptureSourceSelectionOptions& options, + std::function)> callback) { + if (!options.allow_displays || !options.allow_windows || !options.parent_window) { + callback(false, nullptr); return; + } + // Keep COM initialized until the asynchronous picker completes. + const DWORD owner_thread = GetCurrentThreadId(); + auto apartment = std::shared_ptr(new Apartment(), [owner_thread](Apartment* value) { + if (GetCurrentThreadId() == owner_thread) delete value; + else { + // COM initialization must be balanced on its originating thread. If + // the UI dispatcher is gone, the OS reclaims this at process teardown. + RunOnMainThread([value] { delete value; }); + } + }); + auto hwnd = static_cast(options.parent_window->GetNativeObject()); + if (!IsWindow(hwnd)) { callback(false, nullptr); return; } + GraphicsCapturePicker picker; + check_hresult(picker.as()->Initialize(hwnd)); + auto operation = picker.PickSingleItemAsync(); + operation.Completed([callback, parent = options.parent_window, apartment](auto operation, auto status) { + std::shared_ptr result; + bool ok = false; + try { + if (status == winrt::Windows::Foundation::AsyncStatus::Completed && + IsWindow(static_cast(parent->GetNativeObject()))) { + auto item = operation.GetResults(); + if (item) { + auto target = std::make_shared(); + target->item = item; + target->Observe(); + result = target; + } + ok = true; + } + } catch (...) {} + // Release the originating apartment on the UI thread as well. + RunOnMainThread([callback, result, ok, apartment] { callback(ok, result); }); + }); +} +std::shared_ptr CaptureFrameImage(const CaptureFrame& frame) { + Apartment apartment; + com_ptr factory; + check_hresult(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(factory.put()))); + com_ptr stream; + check_hresult(CreateStreamOnHGlobal(nullptr, TRUE, stream.put())); + com_ptr encoder; + check_hresult(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.put())); + check_hresult(encoder->Initialize(stream.get(), WICBitmapEncoderNoCache)); + com_ptr output; + check_hresult(encoder->CreateNewFrame(output.put(), nullptr)); + check_hresult(output->Initialize(nullptr)); + check_hresult(output->SetSize(frame.width, frame.height)); + WICPixelFormatGUID format = GUID_WICPixelFormat32bppBGRA; + check_hresult(output->SetPixelFormat(&format)); + if (format != GUID_WICPixelFormat32bppBGRA) return nullptr; + check_hresult(output->WritePixels(frame.height, frame.width * 4, UINT(frame.pixels.size()), + const_cast(frame.pixels.data()))); + check_hresult(output->Commit()); + check_hresult(encoder->Commit()); + HGLOBAL memory = nullptr; + check_hresult(GetHGlobalFromStream(stream.get(), &memory)); + STATSTG stat{}; + check_hresult(stream->Stat(&stat, STATFLAG_NONAME)); + auto bytes = static_cast(GlobalLock(memory)); + if (!bytes) return nullptr; + struct Unlock { HGLOBAL memory; ~Unlock() { GlobalUnlock(memory); } } unlock{memory}; + static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string base64; + size_t length = size_t(stat.cbSize.QuadPart); + base64.reserve((length + 2) / 3 * 4); + for (size_t i = 0; i < length; i += 3) { + unsigned v = unsigned(bytes[i]) << 16; + if (i + 1 < length) v |= unsigned(bytes[i + 1]) << 8; + if (i + 2 < length) v |= bytes[i + 2]; + base64 += alphabet[v >> 18]; base64 += alphabet[(v >> 12) & 63]; + base64 += i + 1 < length ? alphabet[(v >> 6) & 63] : '='; + base64 += i + 2 < length ? alphabet[v & 63] : '='; + } + return Image::FromBase64(base64); +} +std::unique_ptr CreateCaptureEncoder(const std::string& path, int width, int height, + const ScreenRecordingOptions& options, std::string& error) { + try { + auto encoder = std::make_unique(); + check_hresult(encoder->media.result); + encoder->rate = options.frame_rate; + com_ptr attributes; + check_hresult(MFCreateAttributes(attributes.put(), 2)); + check_hresult(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE)); + check_hresult(MFCreateSinkWriterFromURL(to_hstring(path).c_str(), nullptr, attributes.get(), encoder->writer.put())); + com_ptr output; + check_hresult(MFCreateMediaType(output.put())); + check_hresult(output->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video)); + check_hresult(output->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264)); + check_hresult(output->SetUINT32(MF_MT_AVG_BITRATE, options.video_bitrate ? UINT32(options.video_bitrate) : + UINT32(std::clamp(double(width) * height * options.frame_rate * .15, 1000000.0, 50000000.0)))); + check_hresult(output->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive)); + check_hresult(MFSetAttributeSize(output.get(), MF_MT_FRAME_SIZE, width, height)); + check_hresult(MFSetAttributeRatio(output.get(), MF_MT_FRAME_RATE, UINT32(std::lround(options.frame_rate * 1000)), 1000)); + check_hresult(MFSetAttributeRatio(output.get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1)); + check_hresult(output->SetUINT32(MF_MT_VIDEO_PRIMARIES, MFVideoPrimaries_BT709)); + check_hresult(output->SetUINT32(MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709)); + check_hresult(output->SetUINT32(MF_MT_TRANSFER_FUNCTION, MFVideoTransFunc_sRGB)); + check_hresult(output->SetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, MFNominalRange_16_235)); + check_hresult(encoder->writer->AddStream(output.get(), &encoder->index)); + com_ptr input; + check_hresult(MFCreateMediaType(input.put())); + check_hresult(output->CopyAllItems(input.get())); + check_hresult(input->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12)); + check_hresult(encoder->writer->SetInputMediaType(encoder->index, input.get(), nullptr)); + check_hresult(encoder->writer->BeginWriting()); + return encoder; + } catch (const hresult_error& e) { error = Failure(e); return nullptr; } +} +} // namespace nativeapi::detail diff --git a/src/screen_capture_backend.h b/src/screen_capture_backend.h new file mode 100644 index 00000000..8edc43e7 --- /dev/null +++ b/src/screen_capture_backend.h @@ -0,0 +1,101 @@ +#pragma once + +// Private platform seam; deliberately absent from API_HEADERS. +#include +#include +#include +#include +#include +#include +#include + +#include "screen_recorder.h" + +namespace nativeapi::detail { +using CaptureClock = std::chrono::steady_clock; + +// Owned, tightly packed, top-down SDR BGRA pixels. No borrowed native surfaces. +struct CaptureFrame { + int width = 0; + int height = 0; + CaptureClock::time_point time; + std::vector pixels; +}; + +class CaptureStream { + public: + virtual ~CaptureStream() = default; + // Called from one worker. nullptr + empty error is a normal idle timeout. + virtual std::shared_ptr NextFrame(int timeout_ms, std::string& error) = 0; + virtual bool Stop(std::string& error) = 0; +}; + +class CaptureTarget { + public: + virtual ~CaptureTarget() = default; + virtual CaptureSourceType Type() const = 0; + virtual std::string Name() const = 0; + // Worker-only; must honor cancellation and bound native startup waits. + // Cancellation returns nullptr with an empty error; independent failures + // keep their diagnostics even when cancellation is concurrently requested. + virtual std::unique_ptr Open(const ScreenCaptureOptions& options, + double frame_rate, const std::atomic& cancel, std::string& error) = 0; +}; + +class CaptureEncoder { + public: + virtual ~CaptureEncoder() = default; + virtual bool Append(const CaptureFrame& frame, double seconds, std::string& error) = 0; + // Extends the final sample through end_seconds, including a static trailing scene. + virtual bool Finish(double end_seconds, std::string& error) = 0; +}; + +bool CaptureBackendSupported(); +bool CapturePickerSupported(); +bool CaptureEncodingSupported(); +std::shared_ptr CaptureDisplayTarget(std::shared_ptr display); +std::shared_ptr CaptureWindowTarget(std::shared_ptr window); +void SelectCaptureTarget(const CaptureSourceSelectionOptions& options, + std::function)> callback); +std::shared_ptr CaptureFrameImage(const CaptureFrame& frame); +std::unique_ptr CreateCaptureEncoder(const std::string& temporary_path, + int width, int height, const ScreenRecordingOptions& options, std::string& error); + +// The shared source state is separate from platform resources, and outlives tasks. +struct CaptureAccess { + static std::shared_ptr Wrap(std::shared_ptr target); + static std::shared_ptr Acquire(const std::shared_ptr& source); + static void Release(const std::shared_ptr& source); +}; +class CaptureLease { + public: + explicit CaptureLease(std::shared_ptr source); + ~CaptureLease(); + CaptureLease(const CaptureLease&) = delete; + CaptureLease& operator=(const CaptureLease&) = delete; + std::shared_ptr target; + private: + std::shared_ptr source_; +}; + +bool ValidCaptureOptions(const ScreenCaptureOptions& options); +bool ValidRecordingOptions(const ScreenRecordingOptions& options); +bool ValidRecordingPath(const std::string& path); +bool CropCaptureFrame(const CaptureFrame& input, const ScreenCaptureOptions& options, + CaptureFrame& output); +bool FitCaptureFrame(const CaptureFrame& input, int width, int height, CaptureFrame& output); + +// Exclusively owns a temporary file; publication never overwrites an existing entry. +class CaptureOutputFile { + public: + ~CaptureOutputFile(); + bool Create(const std::string& destination, std::string& error); + bool Publish(std::string& error); + bool Discard(std::string& error); + const std::string& TemporaryPath() const { return temporary_; } + private: + std::string destination_; + std::string temporary_; + std::string directory_; +}; +} // namespace nativeapi::detail diff --git a/src/screen_capture_common.cpp b/src/screen_capture_common.cpp new file mode 100644 index 00000000..589f0fd4 --- /dev/null +++ b/src/screen_capture_common.cpp @@ -0,0 +1,177 @@ +#include "screen_capture_backend.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +namespace nativeapi::detail { +namespace { +constexpr int kMaxDimension = 32768; +bool FrameValid(const CaptureFrame& frame) { + return frame.width > 0 && frame.height > 0 && frame.width <= kMaxDimension && + frame.height <= kMaxDimension && + frame.pixels.size() == size_t(frame.width) * size_t(frame.height) * 4; +} +bool ValidUtf8(const std::string& s) { + size_t i = 0; + while (i < s.size()) { + uint32_t c = static_cast(s[i++]); + if (!c) return false; + if (c < 0x80) continue; + int count; + uint32_t minimum; + if (c >= 0xc2 && c <= 0xdf) { count = 1; c &= 0x1f; minimum = 0x80; } + else if (c >= 0xe0 && c <= 0xef) { count = 2; c &= 0x0f; minimum = 0x800; } + else if (c >= 0xf0 && c <= 0xf4) { count = 3; c &= 7; minimum = 0x10000; } + else return false; + while (count--) { + if (i == s.size()) return false; + uint8_t next = static_cast(s[i++]); + if ((next & 0xc0) != 0x80) return false; + c = (c << 6) | (next & 0x3f); + } + if (c < minimum || c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) return false; + } + return true; +} +} // namespace + +bool ValidCaptureOptions(const ScreenCaptureOptions& options) { + if (!options.crop_enabled) return true; + auto r = options.crop; + for (double n : {r.x, r.y, r.width, r.height}) + if (!std::isfinite(n) || std::floor(n) != n || n < 0 || n > kMaxDimension) return false; + return r.width > 0 && r.height > 0; +} +bool ValidRecordingOptions(const ScreenRecordingOptions& options) { + return ValidCaptureOptions(options.capture) && std::isfinite(options.frame_rate) && + options.frame_rate >= 0.1 && options.frame_rate <= 240.0 && + options.video_bitrate <= std::numeric_limits::max(); +} +bool ValidRecordingPath(const std::string& path) { + if (path.empty() || !ValidUtf8(path)) return false; + try { + auto p = std::filesystem::u8path(path); + if (!p.is_absolute()) return false; + auto extension = p.extension().u8string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return char(std::tolower(c)); }); + return extension == ".mp4"; + } catch (...) { return false; } +} +bool CropCaptureFrame(const CaptureFrame& input, const ScreenCaptureOptions& options, + CaptureFrame& output) { + if (!FrameValid(input) || !ValidCaptureOptions(options)) return false; + Rectangle r = options.crop_enabled ? options.crop : + Rectangle{0, 0, double(input.width), double(input.height)}; + if (r.x + r.width > input.width || r.y + r.height > input.height) return false; + output.width = int(r.width); + output.height = int(r.height); + output.time = input.time; + output.pixels.resize(size_t(output.width) * output.height * 4); + for (int y = 0; y < output.height; ++y) + std::memcpy(output.pixels.data() + size_t(y) * output.width * 4, + input.pixels.data() + (size_t(y + int(r.y)) * input.width + int(r.x)) * 4, + size_t(output.width) * 4); + return true; +} +bool FitCaptureFrame(const CaptureFrame& input, int width, int height, CaptureFrame& output) { + if (!FrameValid(input) || width <= 0 || height <= 0 || + width > kMaxDimension || height > kMaxDimension) return false; + output.width = width; + output.height = height; + output.time = input.time; + output.pixels.assign(size_t(width) * height * 4, 0); + for (size_t i = 3; i < output.pixels.size(); i += 4) output.pixels[i] = 255; + // First-frame odd dimensions are padded rather than rescaled. + double scale = (width - input.width <= 1 && width >= input.width && + height - input.height <= 1 && height >= input.height) ? 1.0 : + std::min(double(width) / input.width, double(height) / input.height); + int w = std::max(1, int(std::floor(input.width * scale))); + int h = std::max(1, int(std::floor(input.height * scale))); + int left = (width - w) / 2, top = (height - h) / 2; + for (int y = 0; y < h; ++y) { + double sy = std::clamp((y + 0.5) / scale - 0.5, 0.0, double(input.height - 1)); + int y0 = int(sy), y1 = std::min(y0 + 1, input.height - 1); + for (int x = 0; x < w; ++x) { + double sx = std::clamp((x + 0.5) / scale - 0.5, 0.0, double(input.width - 1)); + int x0 = int(sx), x1 = std::min(x0 + 1, input.width - 1); + for (int c = 0; c < 3; ++c) { + auto pixel = [&](int px, int py) { return input.pixels[(size_t(py) * input.width + px) * 4 + c]; }; + double a = pixel(x0, y0) * (1 - sx + x0) + pixel(x1, y0) * (sx - x0); + double b = pixel(x0, y1) * (1 - sx + x0) + pixel(x1, y1) * (sx - x0); + output.pixels[(size_t(y + top) * width + x + left) * 4 + c] = + uint8_t(std::lround(a * (1 - sy + y0) + b * (sy - y0))); + } + } + } + return true; +} + +CaptureOutputFile::~CaptureOutputFile() { std::string ignored; Discard(ignored); } +bool CaptureOutputFile::Create(const std::string& destination, std::string& error) { + if (!temporary_.empty() || !ValidRecordingPath(destination)) { + error = "Invalid recording output path"; return false; + } + try { + auto path = std::filesystem::u8path(destination); + std::error_code ec; + auto status = std::filesystem::symlink_status(path, ec); + if (status.type() != std::filesystem::file_type::not_found || + !std::filesystem::is_directory(path.parent_path())) { + error = "Output already exists or parent directory is unavailable"; return false; + } + std::random_device random; + for (int attempt = 0; attempt < 32; ++attempt) { + auto directory = path.parent_path() / + (".nativeapi-recording-" + std::to_string(random()) + "-" + std::to_string(random())); + if (std::filesystem::create_directory(directory, ec)) { + directory_ = directory.u8string(); + temporary_ = (directory / "video.mp4").u8string(); + destination_ = destination; + return true; + } + if (ec && ec != std::errc::file_exists) break; + } + error = "Cannot reserve a temporary recording directory"; + } catch (...) { error = "Cannot prepare the recording output"; } + return false; +} +bool CaptureOutputFile::Publish(std::string& error) { + if (temporary_.empty()) { error = "Missing temporary recording"; return false; } + try { +#ifdef _WIN32 + bool ok = MoveFileExW(std::filesystem::u8path(temporary_).c_str(), + std::filesystem::u8path(destination_).c_str(), MOVEFILE_WRITE_THROUGH) != FALSE; +#else + // link is atomic and fails with EEXIST, unlike an overwriting rename. + bool ok = link(temporary_.c_str(), destination_.c_str()) == 0; +#endif + if (!ok) { error = "Cannot publish recording without replacing an existing file"; return false; } + // Publication has committed. Never remove destination_, including on cleanup failure. + return Discard(error); + } catch (...) { error = "Recording publication failed"; return false; } +} +bool CaptureOutputFile::Discard(std::string& error) { + if (directory_.empty()) return true; + std::error_code ec; + if (!temporary_.empty()) std::filesystem::remove(std::filesystem::u8path(temporary_), ec); + if (!ec) std::filesystem::remove(std::filesystem::u8path(directory_), ec); + if (ec) { error = "Failed to remove temporary recording: " + directory_; return false; } + temporary_.clear(); + directory_.clear(); + return true; +} +} // namespace nativeapi::detail diff --git a/src/screen_capturer.cpp b/src/screen_capturer.cpp new file mode 100644 index 00000000..107529ca --- /dev/null +++ b/src/screen_capturer.cpp @@ -0,0 +1,101 @@ +#include "screen_capturer.h" + +#include +#include +#include "foundation/dispatcher.h" +#include "screen_capture_backend.h" +#include "window_manager.h" + +namespace nativeapi { +namespace { +std::atomic picker_busy{false}; +template void Deliver(F callback) { + // Dispatcher teardown revokes delivery; never call a UI callback on the worker. + RunOnMainThread(std::move(callback)); +} +} // namespace +bool ScreenCapturer::IsSupported() { + try { return IsMainThreadDispatchSupported() && detail::CaptureBackendSupported(); } + catch (...) { return false; } +} +bool ScreenCapturer::IsSourceSelectionSupported() { + try { return IsSupported() && detail::CapturePickerSupported(); } + catch (...) { return false; } +} +bool ScreenCapturer::IsDirectCaptureSupported(CaptureSourceType type) { + return (type == CaptureSourceType::Display || type == CaptureSourceType::Window) && IsSupported(); +} +void ScreenCapturer::SelectSource(const CaptureSourceSelectionOptions& options, + std::function)> callback) { + if (!callback) return; + if (!IsMainThreadDispatchSupported()) { callback(false, nullptr); return; } + if (!IsSourceSelectionSupported() || (!options.allow_displays && !options.allow_windows) || + picker_busy.exchange(true)) { + Deliver([callback] { callback(false, nullptr); }); return; + } + auto once = std::make_shared>(false); + auto finish = [callback, once, options](bool ok, std::shared_ptr target) { + if (once->exchange(true)) return; + std::shared_ptr source; + try { + if (target) { + auto type = target->Type(); + if ((type == CaptureSourceType::Window && options.allow_windows) || + (type == CaptureSourceType::Display && options.allow_displays) || + (type == CaptureSourceType::Unknown && options.allow_windows && options.allow_displays)) + source = detail::CaptureAccess::Wrap(std::move(target)); + else ok = false; + } + } catch (...) { ok = false; } + Deliver([callback, ok, source] { + picker_busy.store(false); + callback(ok, ok ? source : nullptr); + }); + }; + try { + auto resolved = options; + if (!resolved.parent_window) resolved.parent_window = WindowManager::GetInstance().GetCurrent(); + detail::SelectCaptureTarget(resolved, finish); + } catch (...) { finish(false, nullptr); } +} + +void ScreenCapturer::Capture(std::shared_ptr source, + const ScreenCaptureOptions& options, std::function)> callback) { + if (!callback) return; + if (!IsMainThreadDispatchSupported()) { callback(false, nullptr); return; } + auto lease = std::make_shared(std::move(source)); + if (!lease->target || !IsSupported() || !detail::ValidCaptureOptions(options)) { + lease.reset(); + Deliver([callback] { callback(false, nullptr); }); return; + } + try { + std::thread([lease, callback, options]() mutable { + std::shared_ptr image; + try { + std::atomic cancel{false}; + std::string error; + auto stream = lease->target->Open(options, 30, cancel, error); + if (stream) { + auto deadline = detail::CaptureClock::now() + std::chrono::seconds(15); + while (detail::CaptureClock::now() < deadline && error.empty()) { + auto frame = stream->NextFrame(100, error); + if (!frame) continue; + detail::CaptureFrame cropped; + if (detail::CropCaptureFrame(*frame, options, cropped)) + image = detail::CaptureFrameImage(cropped); + break; + } + if (!stream->Stop(error) || !error.empty()) image.reset(); + } + } catch (...) { image.reset(); } + Deliver([lease = std::move(lease), callback, image]() mutable { + lease.reset(); + callback(image != nullptr, image); + }); + }).detach(); + } catch (...) { + lease.reset(); + Deliver([callback] { callback(false, nullptr); }); + } +} +} // namespace nativeapi diff --git a/src/screen_capturer.h b/src/screen_capturer.h new file mode 100644 index 00000000..15b51015 --- /dev/null +++ b/src/screen_capturer.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#include "capture_source.h" +#include "foundation/geometry.h" + +namespace nativeapi { +class Image; +class Window; + +/** @brief Allowed source types and the owner of the system selection UI. */ +struct CaptureSourceSelectionOptions { + /** Allow a display; at least one source type must be allowed. */ + bool allow_displays = true; + /** Allow a window, including other applications' windows. */ + bool allow_windows = true; + /** Owner of the picker, not the captured window; nullptr uses the current window. */ + std::shared_ptr parent_window; +}; + +/** @brief Shared screenshot and recording configuration, copied when submitted. */ +struct ScreenCaptureOptions { + /** Include the pointer in the captured pixels; unsupported modes fail. */ + bool include_cursor = false; + /** Apply crop; when false the rectangle is ignored. */ + bool crop_enabled = false; + /** Source-local physical pixels, top-left origin, not global logical coordinates. + * All components must be finite integers, x/y >= 0, width/height > 0, in bounds. */ + Rectangle crop = {0, 0, 0, 0}; +}; + +/** + * @brief Asynchronously selects capture targets and obtains independent SDR images. + * + * Calls use the host UI thread and require a running event loop. Completions run + * on a later UI-loop turn exactly once. Without a dispatcher, failure is delivered + * synchronously on the caller thread. An empty callback does nothing. Runtime + * shutdown can revoke pending deliveries. Queries never prompt for permission. + * + * @note Platform availability: + * - macOS: ⚠️ ScreenCaptureKit on macOS 14+; selection uses the system sharing picker + * - Windows: ⚠️ Windows 10 2004+; picker needs an owner window and both source types + * enabled; system-selected sources have type Unknown + * - Linux: ❌ Not implemented - Queries return false and operations fail + * - Android: ❌ Not implemented - Queries return false and operations fail + * - iOS: ❌ Not implemented - Queries return false and operations fail + * - OpenHarmony: ❌ Not implemented - Queries return false and operations fail + * + * @code + * auto source = CaptureSource::FromDisplay(DisplayManager::GetInstance().GetPrimary()); + * ScreenCapturer::Capture(source, {}, [](bool ok, std::shared_ptr image) { + * if (ok) image->SaveToFile("/tmp/screenshot.png"); + * }); + * @endcode + */ +class ScreenCapturer { + public: + /** @brief Checks for a usable capture backend, not permission or target validity. */ + static bool IsSupported(); + /** @brief Checks for an available interactive source selection backend. */ + static bool IsSourceSelectionSupported(); + /** @brief Checks whether a target kind can be specified without the picker. + * @param type Kind to test; None always returns false. */ + static bool IsDirectCaptureSupported(CaptureSourceType type); + + /** @brief Selects exactly one source in system UI; only one picker may be pending. + * @param options Allowed types and picker owner. + * @param callback (true, source) on selection, (true, nullptr) on user cancellation, + * (false, nullptr) on invalid input, denial, unsupported operation or failure. */ + static void SelectSource( + const CaptureSourceSelectionOptions& options, + std::function)> callback); + + /** @brief Captures a new frame, optionally cropping it, without saving a file. + * @param source Target retained until completion; null or busy sources fail. + * @param options Physical-pixel crop and cursor policy. + * @param callback (true, independent image) on success, (false, nullptr) on failure. + * A returned image remains usable after the source and native frame are released. */ + static void Capture(std::shared_ptr source, + const ScreenCaptureOptions& options, + std::function)> callback); + + private: + ScreenCapturer() = delete; +}; +} // namespace nativeapi diff --git a/src/screen_recorder.cpp b/src/screen_recorder.cpp new file mode 100644 index 00000000..5c5933a5 --- /dev/null +++ b/src/screen_recorder.cpp @@ -0,0 +1,218 @@ +#include "screen_recorder.h" + +#include +#include +#include +#include +#include "foundation/dispatcher.h" +#include "screen_capture_backend.h" + +namespace nativeapi { +namespace { +using detail::CaptureClock; +struct RecordingSession { + std::mutex mutex; + ScreenRecordingState state = ScreenRecordingState::Idle; + std::atomic cancel{false}; + std::atomic stop{false}; + bool destroyed = false; + double duration = 0; + std::string path; + CaptureClock::time_point stopped_at; + std::function notify; +}; + +void Notify(const std::shared_ptr& session, ScreenRecordingState state, + const std::string& message = {}) { + auto notify = session->notify; + RunOnMainThread([notify, state, message] { notify(state, message); }); +} + +void Record(const std::shared_ptr& session, + std::shared_ptr lease, ScreenRecordingOptions options) { + detail::CaptureOutputFile output; + std::unique_ptr stream; + std::unique_ptr encoder; + std::string error; + bool have_frame = false; + CaptureClock::time_point first_time; + double last_time = -1; + int width = 0, height = 0; + auto finish_state = ScreenRecordingState::Failed; + try { + if (!session->cancel && output.Create(session->path, error)) + stream = lease->target->Open(options.capture, options.frame_rate, session->cancel, error); + if (!stream && error.empty() && !session->cancel) error = "Cannot open the capture stream"; + auto deadline = CaptureClock::now() + std::chrono::seconds(15); + while (stream && error.empty() && !session->cancel && !session->stop) { + auto frame = stream->NextFrame(100, error); + if (!frame) { + if (!have_frame && CaptureClock::now() >= deadline) error = "Timed out waiting for the first frame"; + continue; + } + double seconds = have_frame ? std::chrono::duration(frame->time - first_time).count() : 0; + if (have_frame && seconds < last_time + 1.0 / options.frame_rate) continue; + detail::CaptureFrame cropped, fitted; + if (!detail::CropCaptureFrame(*frame, options.capture, cropped)) { + error = "Capture crop is outside the current source image"; break; + } + if (!encoder) { + width = (cropped.width + 1) & ~1; + height = (cropped.height + 1) & ~1; + encoder = detail::CreateCaptureEncoder(output.TemporaryPath(), width, height, options, error); + if (!encoder) { if (error.empty()) error = "Cannot create an H.264 encoder"; break; } + } + if (!detail::FitCaptureFrame(cropped, width, height, fitted) || + !encoder->Append(fitted, seconds, error)) { + if (error.empty()) error = "Cannot encode a video frame"; + break; + } + bool started = false; + { + std::lock_guard lock(session->mutex); + if (session->cancel) break; + if (!have_frame) { + first_time = frame->time; + session->state = ScreenRecordingState::Recording; + started = true; + } + have_frame = true; + last_time = seconds; + session->duration = seconds; + } + if (started) Notify(session, ScreenRecordingState::Recording); + } + if (stream) { + std::string stop_error; + if (!stream->Stop(stop_error)) { + if (error.empty()) error = stop_error.empty() ? "Cannot stop capture" : stop_error; + } + stream.reset(); + } + if (session->cancel && error.empty()) { + // Only the backend's explicit cancellation path has no error. Do not + // hide an independent encoder/write failure racing with Cancel(). + encoder.reset(); + finish_state = output.Discard(error) ? ScreenRecordingState::Cancelled : ScreenRecordingState::Failed; + } else if (error.empty() && have_frame && session->stop) { + double duration; + { + std::lock_guard lock(session->mutex); + duration = std::max(last_time + 0.001, + std::chrono::duration(session->stopped_at - first_time).count()); + } + if (encoder->Finish(duration, error)) { + encoder.reset(); + // The publication and destruction decisions share this lock. A dead + // recorder cannot publish, and already published files are never removed. + std::lock_guard lock(session->mutex); + if (!session->destroyed && !session->cancel && output.Publish(error)) { + session->duration = duration; + finish_state = ScreenRecordingState::Completed; + session->state = finish_state; + } + } + } + } catch (const std::exception& e) { error = e.what(); } + catch (...) { error = "Unexpected screen recording failure"; } + if (stream) { std::string ignored; stream->Stop(ignored); } + encoder.reset(); + std::string cleanup_error; + if (!output.Discard(cleanup_error)) { + if (!error.empty()) error += "; "; + error += cleanup_error; + finish_state = ScreenRecordingState::Failed; + } + lease.reset(); + { + std::lock_guard lock(session->mutex); + if (session->destroyed) return; + session->state = finish_state; + } + if (finish_state == ScreenRecordingState::Failed && error.empty()) error = "Screen recording failed"; + Notify(session, finish_state, error); +} +} // namespace + +class ScreenRecorder::Impl { + public: + std::shared_ptr session = std::make_shared(); +}; +ScreenRecorder::ScreenRecorder() : pimpl_(std::make_unique()) { + std::function notify = + [this](ScreenRecordingState state, std::string message) { + switch (state) { + case ScreenRecordingState::Recording: Emit(); break; + case ScreenRecordingState::Completed: + Emit(GetFilePath(), GetDuration()); break; + case ScreenRecordingState::Cancelled: Emit(); break; + case ScreenRecordingState::Failed: Emit(message); break; + default: break; + } + }; + pimpl_->session->notify = CreateGuardedCallback(std::move(notify)); +} +ScreenRecorder::~ScreenRecorder() { + ShutdownEmitter(); + auto session = pimpl_->session; + std::lock_guard lock(session->mutex); + session->destroyed = true; + session->cancel = true; +} +bool ScreenRecorder::IsSupported() { + try { return ScreenCapturer::IsSupported() && detail::CaptureEncodingSupported(); } + catch (...) { return false; } +} +bool ScreenRecorder::Start(std::shared_ptr source, const std::string& path, + const ScreenRecordingOptions& options) { + try { + if (!IsSupported() || !detail::ValidRecordingPath(path) || + !detail::ValidRecordingOptions(options)) return false; + auto session = pimpl_->session; + std::lock_guard lock(session->mutex); + if (session->state != ScreenRecordingState::Idle) return false; + auto lease = std::make_shared(std::move(source)); + if (!lease->target) return false; + session->path = path; + session->state = ScreenRecordingState::Starting; + try { std::thread(Record, session, std::move(lease), options).detach(); } + catch (...) { session->state = ScreenRecordingState::Idle; session->path.clear(); return false; } + return true; + } catch (...) { return false; } +} +bool ScreenRecorder::Stop() { + auto session = pimpl_->session; + std::lock_guard lock(session->mutex); + if (session->state != ScreenRecordingState::Recording) return false; + session->state = ScreenRecordingState::Stopping; + session->stopped_at = CaptureClock::now(); + session->stop = true; + return true; +} +bool ScreenRecorder::Cancel() { + auto session = pimpl_->session; + std::lock_guard lock(session->mutex); + if (session->state != ScreenRecordingState::Starting && + session->state != ScreenRecordingState::Recording) return false; + session->state = ScreenRecordingState::Stopping; + session->cancel = true; + return true; +} +ScreenRecordingState ScreenRecorder::GetState() const { + std::lock_guard lock(pimpl_->session->mutex); + return pimpl_->session->state; +} +bool ScreenRecorder::IsActive() const { + auto state = GetState(); + return state == ScreenRecordingState::Starting || state == ScreenRecordingState::Recording || + state == ScreenRecordingState::Stopping; +} +double ScreenRecorder::GetDuration() const { + std::lock_guard lock(pimpl_->session->mutex); + return pimpl_->session->duration; +} +std::string ScreenRecorder::GetFilePath() const { + std::lock_guard lock(pimpl_->session->mutex); + return pimpl_->session->path; +} +} // namespace nativeapi diff --git a/src/screen_recorder.h b/src/screen_recorder.h new file mode 100644 index 00000000..800bc536 --- /dev/null +++ b/src/screen_recorder.h @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include + +#include "foundation/event.h" +#include "foundation/event_emitter.h" +#include "screen_capturer.h" + +namespace nativeapi { +/** @brief State of one recording attempt; terminal recorders cannot restart. */ +enum class ScreenRecordingState { + /** No request has been accepted. */ + Idle, + /** Starting capture and the encoder. */ + Starting, + /** At least one frame has been accepted by the encoder. */ + Recording, + /** Finalizing output or discarding a cancelled recording. */ + Stopping, + /** The finished file has been published. */ + Completed, + /** Cancellation and cleanup completed. */ + Cancelled, + /** Capture, encoding, publication or cleanup failed. */ + Failed, +}; + +/** @brief Configuration for silent SDR H.264 video in an MP4 file. */ +struct ScreenRecordingOptions { + /** Cursor and source-local physical-pixel crop configuration. */ + ScreenCaptureOptions capture; + /** Finite maximum sampling rate in frames per second, in [0.1, 240]. */ + double frame_rate = 30.0; + /** Target bits per second, at most UINT32_MAX; zero selects an encoder default. */ + size_t video_bitrate = 0; +}; + +/** @brief Base of recording notifications, emitted by the individual recorder. */ +class ScreenRecordingEvent : public Event { + public: + std::string GetTypeName() const override { return "ScreenRecordingEvent"; } +}; +/** @brief The first video frame has been accepted. */ +class ScreenRecordingStartedEvent : public ScreenRecordingEvent { + public: + std::string GetTypeName() const override { return "ScreenRecordingStartedEvent"; } +}; +/** @brief Encoding, file closing and non-overwriting publication succeeded. */ +class ScreenRecordingCompletedEvent : public ScreenRecordingEvent { + public: + ScreenRecordingCompletedEvent(const std::string& file_path, double duration) + : file_path_(file_path), duration_(duration) {} + /** @brief Gets the finalized absolute file path. */ + std::string GetFilePath() const { return file_path_; } + /** @brief Gets the media duration in seconds, excluding authorization time. */ + double GetDuration() const { return duration_; } + std::string GetTypeName() const override { return "ScreenRecordingCompletedEvent"; } + private: + std::string file_path_; + double duration_; +}; +/** @brief The recording was discarded and temporary output cleaned up. */ +class ScreenRecordingCancelledEvent : public ScreenRecordingEvent { + public: + std::string GetTypeName() const override { return "ScreenRecordingCancelledEvent"; } +}; +/** @brief A recording failed; diagnostics are not stable machine-readable codes. */ +class ScreenRecordingFailedEvent : public ScreenRecordingEvent { + public: + explicit ScreenRecordingFailedEvent(const std::string& message) : message_(message) {} + /** @brief Gets a human-readable diagnostic, including cleanup failure when relevant. */ + std::string GetMessage() const { return message_; } + std::string GetTypeName() const override { return "ScreenRecordingFailedEvent"; } + private: + std::string message_; +}; + +/** + * @brief Records one source as silent SDR H.264/MP4 using a native capture backend. + * + * Calls use the host UI thread. Start/Stop/Cancel report acceptance, not completion. + * Keep the recorder alive until a terminal event. Destruction discards unfinished + * output and suppresses further events; it never implicitly saves. No listener is + * required for recording to progress. Output dimensions are fixed by the first frame; + * resized content is fitted with black bars. Requested crops must remain in bounds. + * + * @note Platform availability: + * - macOS: ⚠️ macOS 14+ ScreenCaptureKit and AVAssetWriter; permission required + * - Windows: ⚠️ Windows 10 2004+ Graphics Capture and Media Foundation H.264 encoder + * - Linux: ❌ Not implemented - Start returns false + * - Android: ❌ Not implemented - Start returns false + * - iOS: ❌ Not implemented - Start returns false + * - OpenHarmony: ❌ Not implemented - Start returns false + * + * @code + * auto recorder = std::make_shared(); + * auto source = CaptureSource::FromDisplay(DisplayManager::GetInstance().GetPrimary()); + * bool accepted = recorder->Start(source, "/tmp/recording.mp4", {}); + * // Retain recorder; after StartedEvent call Stop and await a terminal event. + * @endcode + */ +class ScreenRecorder : public EventEmitter { + public: + /** @brief Checks for capture and H.264/MP4 encoding support, without prompting. */ + static bool IsSupported(); + ScreenRecorder(); + ~ScreenRecorder() override; + ScreenRecorder(const ScreenRecorder&) = delete; + ScreenRecorder& operator=(const ScreenRecorder&) = delete; + ScreenRecorder(ScreenRecorder&&) = delete; + ScreenRecorder& operator=(ScreenRecorder&&) = delete; + + /** @brief Starts one recording asynchronously from Idle. + * @param source Target retained until termination. + * @param file_path Absolute UTF-8 local .mp4 path; existing entries are never replaced. + * @param options Sampling, bitrate, crop and cursor configuration. + * @return false on synchronous rejection; state remains Idle and no event is emitted. + * true means Starting; later failures are reported by ScreenRecordingFailedEvent. */ + bool Start(std::shared_ptr source, const std::string& file_path, + const ScreenRecordingOptions& options); + /** @brief Stops a Recording session and asynchronously publishes the finished file. + * @return false outside Recording. Await CompletedEvent or FailedEvent after true. */ + bool Stop(); + /** @brief Discards a Starting or Recording session asynchronously. + * @return false in other states, including Stopping. Await CancelledEvent or FailedEvent. */ + bool Cancel(); + /** @brief Gets the current recording state. */ + ScreenRecordingState GetState() const; + /** @brief Checks for Starting, Recording or Stopping. */ + bool IsActive() const; + /** @brief Gets media duration in seconds; zero before the first frame. + * On failure/cancellation this is diagnostic, not a promise of a saved file. */ + double GetDuration() const; + /** @brief Gets the requested absolute path, empty before acceptance. + * Only Completed guarantees that a finalized file was published there. */ + std::string GetFilePath() const; + + private: + class Impl; + std::unique_ptr pimpl_; +}; +} // namespace nativeapi diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 368523d5..2ba7fce8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,10 @@ if(APPLE) endif() add_executable(url_opener_test url_opener_test.cpp) +add_executable(screen_capture_test screen_capture_test.cpp) +target_link_libraries(screen_capture_test PRIVATE nativeapi) +add_test(NAME screen_capture_test COMMAND screen_capture_test) +set_tests_properties(screen_capture_test PROPERTIES TIMEOUT 40) target_link_libraries(url_opener_test PRIVATE nativeapi) add_test(NAME url_opener_test COMMAND url_opener_test) @@ -30,6 +34,9 @@ add_test(NAME screen_layout_test COMMAND screen_layout_test) # window_manager#412 / #572 (#75): Maximize() before the first show. if(WIN32) + # Explicit interactive-desktop integration; never run under session-0 CTest. + add_executable(screen_capture_windows_smoke screen_capture_windows_smoke.cpp) + target_link_libraries(screen_capture_windows_smoke PRIVATE nativeapi propsys) add_executable(tray_template_windows_test tray_template_windows_test.cpp) target_link_libraries(tray_template_windows_test PRIVATE nativeapi) add_test(NAME tray_template_windows_test COMMAND tray_template_windows_test) @@ -99,6 +106,9 @@ target_link_libraries(window_shadow_blur_test PRIVATE nativeapi) add_test(NAME window_shadow_blur_test COMMAND window_shadow_blur_test) if(APPLE AND NOT CMAKE_SYSTEM_NAME STREQUAL "iOS") + # Explicit desktop integration: captures only its own colored test window. + add_executable(screen_capture_macos_smoke screen_capture_macos_smoke.mm) + target_link_libraries(screen_capture_macos_smoke PRIVATE nativeapi) add_executable(tray_template_macos_test tray_template_macos_test.mm) target_compile_options(tray_template_macos_test PRIVATE -fobjc-arc) target_link_libraries(tray_template_macos_test PRIVATE nativeapi "-framework Cocoa") diff --git a/tests/build_capture_windows.ps1 b/tests/build_capture_windows.ps1 new file mode 100644 index 00000000..789a131a --- /dev/null +++ b/tests/build_capture_windows.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'Stop' +$build = 'build-capture' +cmake -S . -B $build -DCMAKE_CXX_FLAGS=/utf-8 +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +cmd /c "cmake --build $build --config Debug --target screen_capture_test screen_capture_windows_smoke -- /nologo /v:minimal > $build\capture-build.log 2>&1" +$result = $LASTEXITCODE +Get-Content "$build\capture-build.log" | Select-String 'error |fatal |vcxproj ->|PASS|FAIL' +if ($result -ne 0) { exit $result } +& "$build\tests\Debug\screen_capture_test.exe" +exit $LASTEXITCODE diff --git a/tests/run_capture_windows.ps1 b/tests/run_capture_windows.ps1 new file mode 100644 index 00000000..679416e5 --- /dev/null +++ b/tests/run_capture_windows.ps1 @@ -0,0 +1,52 @@ +# Runs only our capture smoke executable in the logged-on desktop; no input. +param([switch]$DesktopSession, [string]$RunDirectory) +$ErrorActionPreference = 'Stop' +$workspace = Split-Path $PSScriptRoot -Parent +$exe = Join-Path $workspace 'build-capture\tests\Debug\screen_capture_windows_smoke.exe' +if (-not (Test-Path $exe)) { throw "Missing executable: $exe" } +if ($DesktopSession) { + $result = @{ session = (Get-Process -Id $PID).SessionId; exitCode = 1 } + $process = $null + try { + $process = Start-Process $exe -WorkingDirectory $workspace -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $RunDirectory 'stdout.log') -RedirectStandardError (Join-Path $RunDirectory 'stderr.log') + $process.Handle | Out-Null + if (-not $process.WaitForExit(90000)) { throw 'Capture smoke timed out' } + $result.exitCode = $process.ExitCode + } catch { + $_ | Out-String | Out-File (Join-Path $RunDirectory 'launcher-error.log') -Encoding utf8 + } finally { + if ($process -and -not $process.HasExited) { $process.Kill(); $process.WaitForExit() } + $result | ConvertTo-Json | Out-File (Join-Path $RunDirectory 'result.json') -Encoding utf8 + } + exit $result.exitCode +} +if (-not (Get-Process explorer -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -ne 0 })) { + throw 'The host has no logged-on desktop session' +} +$runId = [Guid]::NewGuid().ToString('N') +$RunDirectory = Join-Path $workspace ('build-capture\desktop-' + $runId) +New-Item -ItemType Directory -Path $RunDirectory | Out-Null +$taskName = 'NativeAPI-Capture-' + $runId +$arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $PSCommandPath + '" -DesktopSession -RunDirectory "' + $RunDirectory + '"' +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments +$principal = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value) -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::FromMinutes(2)) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +try { + Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings | Out-Null + Start-ScheduledTask -TaskName $taskName + $deadline = (Get-Date).AddSeconds(110) + $resultFile = Join-Path $RunDirectory 'result.json' + while (-not (Test-Path $resultFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 } + if (-not (Test-Path $resultFile)) { throw "Desktop check timed out: $RunDirectory" } + foreach ($name in @('stdout.log', 'stderr.log', 'launcher-error.log')) { + $log = Join-Path $RunDirectory $name + if (Test-Path $log) { Get-Content $log } + } + $result = Get-Content $resultFile -Raw | ConvertFrom-Json + $result | ConvertTo-Json + Write-Output "RESULTS $RunDirectory" + if ($result.session -eq 0 -or $result.exitCode -ne 0) { throw 'Desktop capture check did not pass' } +} finally { + Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} diff --git a/tests/screen_capture_macos_smoke.mm b/tests/screen_capture_macos_smoke.mm new file mode 100644 index 00000000..009631bb --- /dev/null +++ b/tests/screen_capture_macos_smoke.mm @@ -0,0 +1,120 @@ +#import +#import + +#include "../src/foundation/dispatcher.h" +#include "../src/image.h" +#include "../src/screen_recorder.h" +#include "../src/window.h" +#include +#include +#include + +using namespace nativeapi; +static bool Wait(const std::function& done, int seconds = 25) { + auto end = std::chrono::steady_clock::now() + std::chrono::seconds(seconds); + while (!done() && std::chrono::steady_clock::now() < end) RunMainThreadLoopFor(10); + return done(); +} + +int main(int argc, char** argv) { + @autoreleasepool { + if (!CGPreflightScreenCaptureAccess()) { + std::cout << "SKIP: screen recording permission is required for this executable/host\n"; + return 77; + } + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + SetMainThread(); + if (!ScreenCapturer::IsSupported() || !ScreenRecorder::IsSupported()) { + std::cout << "SKIP: capture/encoder backend unavailable\n"; return 77; + } + auto directory = std::filesystem::temp_directory_path() / + ("nativeapi-capture-smoke-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directory(directory); + std::string png = (directory / "screenshot.png").string(); + std::string mp4 = (directory / "recording.mp4").string(); + NSWindow* native = [[NSWindow alloc] initWithContentRect:NSMakeRect(150, 150, 240, 180) + styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + native.title = @"nativeapi capture verification"; + native.backgroundColor = NSColor.redColor; + native.releasedWhenClosed = NO; + [native orderFrontRegardless]; + auto window = std::make_shared((__bridge void*)native); + auto source = CaptureSource::FromWindow(window); + bool done = false, screenshot_ok = false; + ScreenCaptureOptions crop; + crop.crop_enabled = true; crop.crop = {16, 50, 64, 64}; + ScreenCapturer::Capture(source, crop, [&](bool ok, std::shared_ptr image) { + if (ok && image && image->GetSize().width == 64 && image->GetSize().height == 64) { + NSImage* nsImage = (__bridge NSImage*)image->GetNativeObject(); + NSBitmapImageRep* rep = [NSBitmapImageRep imageRepWithData:nsImage.TIFFRepresentation]; + NSColor* color = [[rep colorAtX:32 y:32] colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; + screenshot_ok = color.redComponent > .8 && color.blueComponent < .2 && image->SaveToFile(png); + } + done = true; + }); + if (!Wait([&] { return done; }) || !screenshot_ok) { + std::cerr << "FAIL: screenshot/crop/pixel verification\n"; [native close]; return 1; + } + auto recorder = std::make_shared(); + bool started = false, completed = false, failed = false; + std::string failure; + recorder->AddListener([&](const auto&) { started = true; }); + recorder->AddListener([&](const auto&) { completed = true; }); + recorder->AddListener([&](const auto& event) { failed = true; failure = event.GetMessage(); }); + if (!recorder->Start(source, mp4, {}) || !Wait([&] { return started || failed; }) || failed) { + std::cerr << "FAIL: start recording: " << failure << '\n'; [native close]; return 1; + } + auto recording_start = std::chrono::steady_clock::now(); + bool changed = false; + while (std::chrono::steady_clock::now() - recording_start < std::chrono::milliseconds(1600)) { + if (!changed && std::chrono::steady_clock::now() - recording_start > std::chrono::milliseconds(500)) { + native.backgroundColor = NSColor.blueColor; + [native setContentSize:NSMakeSize(360, 240)]; + [native display]; + changed = true; + } + RunMainThreadLoopFor(10); + } + if (!recorder->Stop() || !Wait([&] { return completed || failed; }) || !completed) { + std::cerr << "FAIL: finalize recording: " << failure << '\n'; [native close]; return 1; + } + [native close]; + source.reset(); window.reset(); recorder.reset(); + AVURLAsset* asset = [AVURLAsset URLAssetWithURL:[NSURL fileURLWithPath: + [NSString stringWithUTF8String:mp4.c_str()]] options:nil]; + NSError* error = nil; + AVAssetReader* reader = [[AVAssetReader alloc] initWithAsset:asset error:&error]; + AVAssetTrack* track = [asset tracksWithMediaType:AVMediaTypeVideo].firstObject; + if (!reader || !track || CMTimeGetSeconds(asset.duration) < 1.4) { + std::cerr << "FAIL: readable video/duration\n"; return 1; + } + AVAssetReaderTrackOutput* output = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:track + outputSettings:@{(id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)}]; + [reader addOutput:output]; + if (![reader startReading]) { std::cerr << "FAIL: decoder startup\n"; return 1; } + bool red = false, blue = false; + int count = 0; + size_t initial_width = 0, initial_height = 0; + while (CMSampleBufferRef sample = [output copyNextSampleBuffer]) { + auto pixels = CMSampleBufferGetImageBuffer(sample); + size_t w = CVPixelBufferGetWidth(pixels), h = CVPixelBufferGetHeight(pixels); + if (!count) { initial_width = w; initial_height = h; } + if (w != initial_width || h != initial_height) { std::cerr << "FAIL: changing encoded dimensions\n"; return 1; } + CVPixelBufferLockBaseAddress(pixels, kCVPixelBufferLock_ReadOnly); + auto p = static_cast(CVPixelBufferGetBaseAddress(pixels)) + + (h / 2) * CVPixelBufferGetBytesPerRow(pixels) + (w / 2) * 4; + red |= p[2] > 180 && p[0] < 60; + blue |= p[0] > 180 && p[2] < 60; + CVPixelBufferUnlockBaseAddress(pixels, kCVPixelBufferLock_ReadOnly); + CFRelease(sample); ++count; + } + if (reader.status != AVAssetReaderStatusCompleted || !red || !blue) { + std::cerr << "FAIL: decoded content (red=" << red << ", blue=" << blue << ", frames=" << count << ")\n"; + return 1; + } + std::cout << "PASS: macOS real window screenshot, crop, independent PNG, H.264 decode, resize, static duration\n" + << "ARTIFACTS " << directory.string() << '\n'; + return 0; + } +} diff --git a/tests/screen_capture_test.cpp b/tests/screen_capture_test.cpp new file mode 100644 index 00000000..808543b8 --- /dev/null +++ b/tests/screen_capture_test.cpp @@ -0,0 +1,191 @@ +// Shared contracts use a deterministic backend; desktop smoke tests exercise codecs. +#include "../src/screen_capture_backend.h" +#include "../src/foundation/dispatcher.h" +#include "../src/image.h" + +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(condition) do { if (!(condition)) throw std::runtime_error(#condition); } while (0) +using namespace nativeapi; +using namespace nativeapi::detail; +namespace { +std::atomic fail_open{false}, wait_open{false}, fail_encode{false}; +std::atomic wait_encode{false}, entered_encode{false}; +std::atomic opened{0}, stopped{0}; +class FakeStream : public CaptureStream { + public: + bool first = true; + std::shared_ptr NextFrame(int, std::string&) override { + if (!first) { std::this_thread::sleep_for(std::chrono::milliseconds(2)); return nullptr; } + first = false; + auto frame = std::make_shared(); + frame->width = 3; frame->height = 3; frame->time = CaptureClock::now(); + frame->pixels.assign(36, 255); + return frame; + } + bool Stop(std::string&) override { ++stopped; return true; } +}; +class FakeTarget : public CaptureTarget { + public: + CaptureSourceType Type() const override { return CaptureSourceType::Display; } + std::string Name() const override { return "Test display"; } + std::unique_ptr Open(const ScreenCaptureOptions&, double, + const std::atomic& cancel, std::string& error) override { + ++opened; + while (wait_open && !cancel) std::this_thread::sleep_for(std::chrono::milliseconds(2)); + if (fail_open) { error = "Test startup failure"; return nullptr; } + if (cancel) return nullptr; + return std::make_unique(); + } +}; +class FakeEncoder : public CaptureEncoder { + public: + explicit FakeEncoder(const std::string& path) : output(std::filesystem::u8path(path), std::ios::binary) {} + std::ofstream output; + bool Append(const CaptureFrame&, double, std::string& error) override { + entered_encode = true; + while (wait_encode) std::this_thread::sleep_for(std::chrono::milliseconds(2)); + if (fail_encode) { error = "Test encode failure"; return false; } + output << "frame"; return bool(output); + } + bool Finish(double, std::string&) override { output << "finished"; output.close(); return true; } +}; +template void Until(F predicate) { + auto deadline = CaptureClock::now() + std::chrono::seconds(5); + while (!predicate() && CaptureClock::now() < deadline) RunMainThreadLoopFor(5); + CHECK(predicate()); +} +std::shared_ptr Source() { return CaptureAccess::Wrap(std::make_shared()); } +} // namespace + +namespace nativeapi::detail { +bool CaptureBackendSupported() { return true; } +bool CapturePickerSupported() { return true; } +bool CaptureEncodingSupported() { return true; } +std::shared_ptr CaptureDisplayTarget(std::shared_ptr) { return nullptr; } +std::shared_ptr CaptureWindowTarget(std::shared_ptr) { return nullptr; } +void SelectCaptureTarget(const CaptureSourceSelectionOptions&, + std::function)> callback) { callback(true, nullptr); } +std::shared_ptr CaptureFrameImage(const CaptureFrame&) { return nullptr; } +std::unique_ptr CreateCaptureEncoder(const std::string& path, int, int, + const ScreenRecordingOptions&, std::string&) { return std::make_unique(path); } +} // namespace nativeapi::detail + +int main() { + try { + SetMainThread(); + auto directory = std::filesystem::temp_directory_path() / + ("nativeapi-capture-test-" + std::to_string(CaptureClock::now().time_since_epoch().count())); + std::filesystem::create_directory(directory); + auto path = [&](const char* name) { return (directory / name).u8string(); }; + ScreenCaptureOptions crop; + crop.crop_enabled = true; + crop.crop = {0, 0, 2, 2}; + CHECK(ValidCaptureOptions(crop)); + crop.crop.width = .5; CHECK(!ValidCaptureOptions(crop)); + crop.crop.width = NAN; CHECK(!ValidCaptureOptions(crop)); + crop.crop = {0, 0, 4, 2}; + CaptureFrame input, output; + input.width = 3; input.height = 3; input.pixels.assign(36, 255); + CHECK(!CropCaptureFrame(input, crop, output)); + CHECK(FitCaptureFrame(input, 4, 4, output)); + CHECK(output.pixels[0] == 255 && output.pixels[12] == 0 && output.pixels[15] == 255); + CHECK(!ValidRecordingPath("relative.mp4")); + CHECK(!ValidRecordingPath(path("video.png"))); + CHECK(!ValidRecordingPath(path("video.mp4") + std::string(1, '\0'))); + + { CaptureOutputFile file; std::string error; + CHECK(file.Create(path("race.mp4"), error)); + std::ofstream(std::filesystem::u8path(file.TemporaryPath())) << "new"; + std::ofstream(std::filesystem::u8path(path("race.mp4"))) << "original"; + CHECK(!file.Publish(error)); CHECK(file.Discard(error)); + std::ifstream saved(std::filesystem::u8path(path("race.mp4"))); std::string text; saved >> text; + CHECK(text == "original"); + } + + bool selected = false, inline_call = true; + ScreenCapturer::SelectSource({}, [&](bool ok, auto source) { + CHECK(!inline_call && ok && !source); selected = true; + }); + inline_call = false; Until([&] { return selected; }); + auto source = Source(); + auto lease = std::make_shared(source); + CHECK(lease->target); + bool rejected = false; + ScreenCapturer::Capture(source, {}, [&](bool ok, auto image) { CHECK(!ok && !image); rejected = true; }); + Until([&] { return rejected; }); + lease.reset(); + + { ScreenRecorder recorder; + CHECK(!recorder.Start(source, "relative.mp4", {})); + CHECK(recorder.GetState() == ScreenRecordingState::Idle); + CHECK(!recorder.Stop() && !recorder.Cancel()); + int starts = 0, completes = 0, failures = 0; + recorder.AddListener([&](const auto&) { ++starts; }); + recorder.AddListener([&](const auto& event) { + ++completes; CHECK(event.GetFilePath() == path("success.mp4")); CHECK(event.GetDuration() > 0); + }); + recorder.AddListener([&](const auto&) { ++failures; }); + CHECK(recorder.Start(source, path("success.mp4"), {})); + CHECK(!recorder.Start(source, path("again.mp4"), {})); + Until([&] { return starts == 1; }); + CHECK(recorder.IsActive()); + CHECK(recorder.Stop()); CHECK(!recorder.Cancel() && !recorder.Stop()); + Until([&] { return completes == 1 || failures; }); + CHECK(completes == 1 && failures == 0 && !recorder.IsActive()); + CHECK(std::filesystem::exists(std::filesystem::u8path(path("success.mp4")))); + CHECK(!recorder.Start(source, path("again.mp4"), {})); + } + { ScreenRecorder recorder; int cancels = 0; + wait_open = true; + recorder.AddListener([&](const auto&) { ++cancels; }); + CHECK(recorder.Start(source, path("cancel.mp4"), {})); + CHECK(!recorder.Stop()); CHECK(recorder.Cancel()); + Until([&] { return cancels == 1; }); wait_open = false; + CHECK(!std::filesystem::exists(std::filesystem::u8path(path("cancel.mp4")))); + } + { ScreenRecorder recorder; int failures = 0; + fail_encode = true; + recorder.AddListener([&](const auto& event) { + CHECK(!event.GetMessage().empty()); ++failures; + }); + CHECK(recorder.Start(source, path("fail.mp4"), {})); + Until([&] { return failures == 1; }); fail_encode = false; + CHECK(recorder.GetState() == ScreenRecordingState::Failed); + CHECK(!std::filesystem::exists(std::filesystem::u8path(path("fail.mp4")))); + } + { ScreenRecorder recorder; int failures = 0, cancels = 0; + fail_encode = true; wait_encode = true; entered_encode = false; + recorder.AddListener([&](const auto& event) { + CHECK(event.GetMessage() == "Test encode failure"); ++failures; + }); + recorder.AddListener([&](const auto&) { ++cancels; }); + CHECK(recorder.Start(source, path("cancel-with-encode-error.mp4"), {})); + Until([&] { return entered_encode.load(); }); + CHECK(recorder.Cancel()); + wait_encode = false; + Until([&] { return failures == 1; }); + fail_encode = false; + CHECK(cancels == 0 && recorder.GetState() == ScreenRecordingState::Failed); + CHECK(!std::filesystem::exists(std::filesystem::u8path(path("cancel-with-encode-error.mp4")))); + } + { wait_open = true; + auto recorder = std::make_unique(); + CHECK(recorder->Start(source, path("destroy.mp4"), {})); + recorder.reset(); wait_open = false; + Until([&] { CaptureLease next(source); return next.target != nullptr; }); + CHECK(!std::filesystem::exists(std::filesystem::u8path(path("destroy.mp4")))); + } + for (const auto& entry : std::filesystem::directory_iterator(directory)) + CHECK(entry.path().filename().string().find(".nativeapi-recording-") != 0); + std::filesystem::remove_all(directory); + std::cout << "PASS: screen capture shared contracts\n"; + return 0; + } catch (const std::exception& e) { std::cerr << "FAIL: " << e.what() << '\n'; return 1; } +} diff --git a/tests/screen_capture_windows_smoke.cpp b/tests/screen_capture_windows_smoke.cpp new file mode 100644 index 00000000..d2654c96 --- /dev/null +++ b/tests/screen_capture_windows_smoke.cpp @@ -0,0 +1,194 @@ +// Explicit desktop integration, no synthetic input; only captures our own window. +#define NOMINMAX +#include +#include +#include +#include +#include +#include +#include + +#include "../src/foundation/dispatcher.h" +#include "../src/image.h" +#include "../src/screen_recorder.h" +#include "../src/window.h" +#include +#include +#include +#include + +using namespace nativeapi; +using Microsoft::WRL::ComPtr; +static COLORREF color = RGB(255, 0, 0); +static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_PAINT) { + PAINTSTRUCT paint; + HDC dc = BeginPaint(window, &paint); + RECT rect; GetClientRect(window, &rect); + auto brush = CreateSolidBrush(color); + FillRect(dc, &rect, brush); + DeleteObject(brush); + EndPaint(window, &paint); + return 0; + } + return DefWindowProcW(window, message, wp, lp); +} +static bool Wait(const std::function& done, int seconds = 25) { + auto end = std::chrono::steady_clock::now() + std::chrono::seconds(seconds); + while (!done() && std::chrono::steady_clock::now() < end) RunMainThreadLoopFor(10); + return done(); +} +static void Require(bool value, const std::string& message) { + if (!value) throw std::runtime_error(message); +} +static void Check(HRESULT result, const char* message) { + Require(SUCCEEDED(result), std::string(message) + " HRESULT=" + std::to_string(result)); +} + +static void Decode(const std::filesystem::path& path) { + Check(MFStartup(MF_VERSION), "MF startup"); + struct Shutdown { ~Shutdown() { MFShutdown(); } } shutdown; + ComPtr attributes; + Check(MFCreateAttributes(&attributes, 1), "reader attributes"); + Check(attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE), "video processing"); + ComPtr reader; + Check(MFCreateSourceReaderFromURL(path.c_str(), attributes.Get(), &reader), "open saved video"); + PROPVARIANT duration; + PropVariantInit(&duration); + Check(reader->GetPresentationAttribute(MF_SOURCE_READER_MEDIASOURCE, MF_PD_DURATION, &duration), "duration"); + auto seconds = duration.uhVal.QuadPart / 10000000.0; + PropVariantClear(&duration); + Require(seconds >= 1.4 && seconds < 8, "static video duration: " + std::to_string(seconds)); + ComPtr type; + Check(MFCreateMediaType(&type), "decoder media type"); + Check(type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "video type"); + Check(type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32), "RGB output"); + Check(reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, type.Get()), "decoder format"); + ComPtr actual; + Check(reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &actual), "actual format"); + UINT32 width = 0, height = 0; + Check(MFGetAttributeSize(actual.Get(), MF_MT_FRAME_SIZE, &width, &height), "dimensions"); + Require(width > 0 && height > 0 && !(width % 2) && !(height % 2), "even output dimensions"); + bool red = false, blue = false; + int frames = 0; + LONGLONG previous = -1; + for (;;) { + DWORD flags = 0; + LONGLONG timestamp = 0; + ComPtr sample; + Check(reader->ReadSample(MF_SOURCE_READER_FIRST_VIDEO_STREAM, 0, nullptr, &flags, ×tamp, &sample), "decode frame"); + if (flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) { + ComPtr changed; + Check(reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &changed), "changed media type"); + UINT32 w = 0, h = 0; + Check(MFGetAttributeSize(changed.Get(), MF_MT_FRAME_SIZE, &w, &h), "changed dimensions"); + // The decoder may pad its output to macroblock/stride alignment on the + // first sample. Establish decoded geometry there, then require it to + // remain fixed throughout the resized source's frames. + if (!frames) { width = w; height = h; } + else Require(w == width && h == height, "fixed output dimensions"); + } + if (flags & MF_SOURCE_READERF_ENDOFSTREAM) break; + if (!sample) continue; + Require(timestamp >= previous, "monotonic video timestamps"); + previous = timestamp; + ComPtr buffer; + Check(sample->ConvertToContiguousBuffer(&buffer), "pixel buffer"); + BYTE* pixels = nullptr; DWORD length = 0; + Check(buffer->Lock(&pixels, nullptr, &length), "lock pixels"); + // Center pixel is insensitive to top-down/bottom-up RGB orientation. + size_t offset = (size_t(height / 2) * width + width / 2) * 4; + if (offset + 4 <= length) { + red |= pixels[offset + 2] > 180 && pixels[offset] < 60; + blue |= pixels[offset] > 180 && pixels[offset + 2] < 60; + } + buffer->Unlock(); + ++frames; + } + Require(red && blue && frames >= 2, "decoded red and blue frames; frames=" + std::to_string(frames)); + std::cout << "Decoded " << frames << " frames, " << width << 'x' << height << ", " << seconds << " seconds\n"; +} + +int main() { + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + HRESULT com = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + struct Uninitialize { HRESULT result; ~Uninitialize() { if (SUCCEEDED(result)) CoUninitialize(); } } uninit{com}; + SetMainThread(); + DWORD session = 0; + ProcessIdToSessionId(GetCurrentProcessId(), &session); + if (!session || !ScreenCapturer::IsSupported() || !ScreenRecorder::IsSupported()) { + std::cout << "SKIP: a logged-on desktop and supported capture/encoder are required\n"; + return 77; + } + WNDCLASSW cls{}; + cls.lpfnWndProc = WindowProc; + cls.hInstance = GetModuleHandleW(nullptr); + cls.lpszClassName = L"NativeAPICaptureSmoke"; + RegisterClassW(&cls); + HWND native = CreateWindowExW(WS_EX_TOOLWINDOW, cls.lpszClassName, L"nativeapi capture verification", + WS_OVERLAPPEDWINDOW, 150, 150, 320, 240, nullptr, nullptr, cls.hInstance, nullptr); + if (!native) return 1; + struct CloseWindow { HWND window; ~CloseWindow() { DestroyWindow(window); } } closer{native}; + ShowWindow(native, SW_SHOWNOACTIVATE); + UpdateWindow(native); + try { + auto directory = std::filesystem::temp_directory_path() / + ("nativeapi-capture-smoke-" + std::to_string(GetCurrentProcessId()) + "-" + std::to_string(GetTickCount64())); + std::filesystem::create_directory(directory); + auto png = directory / "screenshot.png"; + auto mp4 = directory / "recording.mp4"; + std::cout << "ARTIFACTS " << directory.u8string() << std::endl; + auto window = std::make_shared(native); + auto source = CaptureSource::FromWindow(window); + Require(source != nullptr, "create window source"); + bool done = false, screenshot_ok = false; + ScreenCaptureOptions crop; + crop.crop_enabled = true; crop.crop = {30, 60, 64, 64}; + std::shared_ptr screenshot; + ScreenCapturer::Capture(source, crop, [&](bool ok, std::shared_ptr image) { + screenshot = std::move(image); + if (ok && screenshot && screenshot->GetSize().width == 64 && screenshot->GetSize().height == 64) { + auto bitmap = static_cast(screenshot->GetNativeObject()); + Gdiplus::Color pixel; + screenshot_ok = bitmap && bitmap->GetPixel(32, 32, &pixel) == Gdiplus::Ok && + pixel.GetR() > 200 && pixel.GetB() < 40 && screenshot->SaveToFile(png.u8string()); + } + done = true; + }); + Require(Wait([&] { return done; }) && screenshot_ok, "screenshot crop and red pixel"); + auto recorder = std::make_shared(); + bool started = false, completed = false, failed = false; + std::string failure; + recorder->AddListener([&](const auto&) { started = true; }); + recorder->AddListener([&](const auto&) { completed = true; }); + recorder->AddListener([&](const auto& event) { failed = true; failure = event.GetMessage(); }); + Require(recorder->Start(source, mp4.u8string(), {}), "accept recording"); + Require(Wait([&] { return started || failed; }) && started, "start recording: " + failure); + auto start = std::chrono::steady_clock::now(); + bool changed = false; + while (std::chrono::steady_clock::now() - start < std::chrono::milliseconds(1600)) { + if (!changed && std::chrono::steady_clock::now() - start > std::chrono::milliseconds(500)) { + std::cout << "Changing to blue and resizing\n"; + color = RGB(0, 0, 255); + SetWindowPos(native, nullptr, 0, 0, 480, 300, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + InvalidateRect(native, nullptr, FALSE); UpdateWindow(native); + changed = true; + } + RunMainThreadLoopFor(10); + } + Require(recorder->Stop(), "accept stop: " + failure); + Require(Wait([&] { return completed || failed; }) && completed, "finalize recording: " + failure); + source.reset(); recorder.reset(); window.reset(); + // The image must still own its original red pixels after capture/recording ended. + Gdiplus::Color pixel; + auto bitmap = static_cast(screenshot->GetNativeObject()); + Require(bitmap->GetPixel(32, 32, &pixel) == Gdiplus::Ok && pixel.GetR() > 200, "independent screenshot storage"); + Decode(mp4); + std::cout << "PASS: Windows real window screenshot, crop, independent PNG, H.264 decode, resize, static duration\n" + << "ARTIFACTS " << directory.u8string() << '\n'; + return 0; + } catch (const std::exception& e) { + std::cerr << "FAIL: " << e.what() << '\n'; + return 1; + } +}