From 833597fd3dff5083c1510f26e2ab5575146ad81c Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 00:55:11 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(linux):=20Wayland(kwin)=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E6=88=AA=E5=9B=BE=E5=92=8C=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/CMakeLists.txt | 4 + source/LibraryHolder/CMakeLists.txt | 4 + .../LibraryHolder/ControlUnit/ControlUnit.cpp | 33 + source/MaaFramework/API/MaaFramework.cpp | 32 + .../API/KWinControlUnitAPI.cpp | 79 ++ source/MaaKWinControlUnit/CMakeLists.txt | 48 + .../Input/UInputController.cpp | 528 ++++++++ .../Input/UInputController.h | 70 + .../Manager/KWinControlUnitMgr.cpp | 235 ++++ .../Manager/KWinControlUnitMgr.h | 63 + .../Screencap/PipeWireScreencap.cpp | 1138 +++++++++++++++++ .../Screencap/PipeWireScreencap.h | 132 ++ .../test_kwin_screencap.cpp | 60 + .../DesktopWindowLinuxFinder.cpp | 2 +- source/include/LibraryHolder/ControlUnit.h | 14 + 15 files changed, 2441 insertions(+), 1 deletion(-) create mode 100644 source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp create mode 100644 source/MaaKWinControlUnit/CMakeLists.txt create mode 100644 source/MaaKWinControlUnit/Input/UInputController.cpp create mode 100644 source/MaaKWinControlUnit/Input/UInputController.h create mode 100644 source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp create mode 100644 source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.h create mode 100644 source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp create mode 100644 source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h create mode 100644 source/MaaKWinControlUnit/test_kwin_screencap.cpp diff --git a/source/CMakeLists.txt b/source/CMakeLists.txt index 3569c4cea3..098f00a25a 100644 --- a/source/CMakeLists.txt +++ b/source/CMakeLists.txt @@ -42,6 +42,10 @@ if(WITH_WLROOTS_CONTROLLER) add_subdirectory(MaaWlRootsControlUnit) endif() +if(WITH_KWIN_CONTROLLER) + add_subdirectory(MaaKWinControlUnit) +endif() + add_subdirectory(LibraryHolder) add_subdirectory(MaaFramework) add_subdirectory(MaaToolkit) diff --git a/source/LibraryHolder/CMakeLists.txt b/source/LibraryHolder/CMakeLists.txt index a3c66ffb10..05fcc401f4 100644 --- a/source/LibraryHolder/CMakeLists.txt +++ b/source/LibraryHolder/CMakeLists.txt @@ -51,4 +51,8 @@ if(WITH_WLROOTS_CONTROLLER) add_dependencies(LibraryHolder MaaWlRootsControlUnit) endif() +if(WITH_KWIN_CONTROLLER) + add_dependencies(LibraryHolder MaaKWinControlUnit) +endif() + source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${library_holder_src}) diff --git a/source/LibraryHolder/ControlUnit/ControlUnit.cpp b/source/LibraryHolder/ControlUnit/ControlUnit.cpp index e671207cfa..d5ac5fa02e 100644 --- a/source/LibraryHolder/ControlUnit/ControlUnit.cpp +++ b/source/LibraryHolder/ControlUnit/ControlUnit.cpp @@ -13,6 +13,7 @@ #include "MaaControlUnit/ReplayControlUnitAPI.h" #include "MaaControlUnit/Win32ControlUnitAPI.h" #include "MaaControlUnit/WlRootsControlUnitAPI.h" +#include "MaaControlUnit/KWinControlUnitAPI.h" #include "MaaUtils/Logger.h" #include "MaaUtils/Runtime.h" @@ -394,4 +395,36 @@ std::shared_ptr MacOSControlUnitLibraryHo return std::shared_ptr(control_unit_handle, destroy_control_unit_func); } +std::shared_ptr + KWinControlUnitLibraryHolder::create_control_unit(const char* device_node, int screen_width, int screen_height) +{ + if (!load_library(library_dir() / libname_)) { + LogError << "Failed to load library" << VAR(library_dir()) << VAR(libname_); + return nullptr; + } + + check_version(version_func_name_); + + auto create_control_unit_func = get_function(create_func_name_); + if (!create_control_unit_func) { + LogError << "Failed to get function create_control_unit"; + return nullptr; + } + + auto destroy_control_unit_func = get_function(destroy_func_name_); + if (!destroy_control_unit_func) { + LogError << "Failed to get function destroy_control_unit"; + return nullptr; + } + + auto control_unit_handle = create_control_unit_func(device_node, screen_width, screen_height); + + if (!control_unit_handle) { + LogError << "Failed to create control unit"; + return nullptr; + } + + return std::shared_ptr(control_unit_handle, destroy_control_unit_func); +} + MAA_NS_END diff --git a/source/MaaFramework/API/MaaFramework.cpp b/source/MaaFramework/API/MaaFramework.cpp index f77b85b317..3cfdf7c15f 100644 --- a/source/MaaFramework/API/MaaFramework.cpp +++ b/source/MaaFramework/API/MaaFramework.cpp @@ -283,6 +283,38 @@ MaaController* MaaWlRootsControllerCreate(const char* wlr_socket_path, MaaBool u #endif } +MaaController* MaaKWinControllerCreate(const char* device_node, int screen_width, int screen_height) +{ + LogFunc << VAR(device_node) << VAR(screen_width) << VAR(screen_height); + +#ifndef __linux__ + + LogError << "This API " << __FUNCTION__ << " is only available on Linux"; + return nullptr; + +#else + + if (!device_node) { + LogError << "device_node is null"; + return nullptr; + } + + if (screen_width <= 0 || screen_height <= 0) { + LogError << "Invalid screen dimensions" << VAR(screen_width) << VAR(screen_height); + return nullptr; + } + + auto control_unit = MAA_NS::KWinControlUnitLibraryHolder::create_control_unit(device_node, screen_width, screen_height); + + if (!control_unit) { + LogError << "Failed to create control unit"; + return nullptr; + } + + return new MAA_CTRL_NS::ControllerAgent(std::move(control_unit)); +#endif +} + void MaaControllerDestroy(MaaController* ctrl) { LogFunc << VAR_VOIDP(ctrl); diff --git a/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp b/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp new file mode 100644 index 0000000000..68eaf919b3 --- /dev/null +++ b/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp @@ -0,0 +1,79 @@ +#include + +#include "MaaControlUnit/KWinControlUnitAPI.h" + +#include "Manager/KWinControlUnitMgr.h" +#include "MaaUtils/Logger.h" + +const char* MaaKWinControlUnitGetVersion() +{ + return MAA_VERSION; +} + +MaaKWinControlUnitHandle MaaKWinControlUnitCreate(const char* device_node, int screen_width, int screen_height) +{ + using namespace MAA_CTRL_UNIT_NS; + + LogFunc << VAR(device_node) << VAR(screen_width) << VAR(screen_height); + + if (!device_node) { + LogError << "device_node is null or empty"; + return nullptr; + } + + if (screen_width <= 0 || screen_height <= 0) { + LogError << "Invalid screen dimensions" << VAR(screen_width) << VAR(screen_height); + return nullptr; + } + + auto unit_mgr = std::make_unique(device_node, screen_width, screen_height); + return unit_mgr.release(); +} + +MaaBool MaaKWinControlUnitConnect(MaaKWinControlUnitHandle handle) +{ + using namespace MAA_CTRL_UNIT_NS; + + LogFunc << VAR_VOIDP(handle); + + if (!handle) { + LogError << "handle is null"; + return false; + } + + return handle->connect(); +} + +MaaBool MaaKWinControlUnitTestScreencap(MaaKWinControlUnitHandle handle) +{ + using namespace MAA_CTRL_UNIT_NS; + + LogFunc << VAR_VOIDP(handle); + + if (!handle) { + LogError << "handle is null"; + return false; + } + + cv::Mat image; + bool ok = handle->screencap(image); + if (ok) { + LogInfo << "screencap succeeded, image size=" << image.size(); + } + else { + LogError << "screencap failed"; + } + return ok; +} + +void MaaKWinControlUnitDestroy(MaaKWinControlUnitHandle handle) +{ + LogFunc << VAR_VOIDP(handle); + + if (!handle) { + LogError << "handle is null"; + return; + } + + delete handle; +} diff --git a/source/MaaKWinControlUnit/CMakeLists.txt b/source/MaaKWinControlUnit/CMakeLists.txt new file mode 100644 index 0000000000..b77efc7e42 --- /dev/null +++ b/source/MaaKWinControlUnit/CMakeLists.txt @@ -0,0 +1,48 @@ +# Find PipeWire and D-Bus packages +find_package(PkgConfig REQUIRED) +pkg_check_modules(PIPEWIRE REQUIRED IMPORTED_TARGET libpipewire-0.3) +pkg_check_modules(DBUS1 REQUIRED IMPORTED_TARGET dbus-1) + +file(GLOB_RECURSE maa_kwin_control_unit_src *.h *.hpp *.cpp) +file(GLOB_RECURSE maa_kwin_control_unit_header ${MAA_PUBLIC_INC}/MaaControlUnit/KWinControlUnitAPI.h ${MAA_PUBLIC_INC}/MaaControlUnit/ControlUnitAPI.h) + +add_library(MaaKWinControlUnit SHARED ${maa_kwin_control_unit_src} ${maa_kwin_control_unit_header}) + +target_include_directories(MaaKWinControlUnit + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${MAA_PRIVATE_INC} ${MAA_PUBLIC_INC} + ${PIPEWIRE_INCLUDE_DIRS} ${DBUS1_INCLUDE_DIRS}) + +target_link_libraries(MaaKWinControlUnit PRIVATE MaaUtils HeaderOnlyLibraries ${OpenCV_LIBS} Boost::system + PkgConfig::PIPEWIRE PkgConfig::DBUS1) + +target_compile_definitions(MaaKWinControlUnit PRIVATE MAA_CONTROL_UNIT_EXPORTS) + +# PipeWire/SPA headers use C99 compound literals, GNU case ranges, and other GNU extensions; +# disable the relevant warnings for PipeWireScreencap.cpp only. +set_source_files_properties( + ${CMAKE_CURRENT_SOURCE_DIR}/Screencap/PipeWireScreencap.cpp + PROPERTIES COMPILE_OPTIONS "-Wno-c99-extensions;-Wno-gnu-statement-expression-from-macro-expansion;-Wno-gnu-zero-variadic-macro-arguments;-Wno-gnu-case-range" +) + +add_dependencies(MaaKWinControlUnit MaaUtils) + +install( + TARGETS MaaKWinControlUnit + RUNTIME DESTINATION bin + LIBRARY DESTINATION bin +) + +# --------------------------------------------------------------------------- +# Integration test (controller + screencap) +# --------------------------------------------------------------------------- +add_executable(test_kwin_screencap test_kwin_screencap.cpp) +target_link_libraries(test_kwin_screencap PRIVATE MaaKWinControlUnit MaaUtils) +target_include_directories(test_kwin_screencap PRIVATE ${MAA_PUBLIC_INC} + ${OpenCV_INCLUDE_DIRS}) +target_compile_options(test_kwin_screencap PRIVATE -Wno-c11-extensions) +add_dependencies(test_kwin_screencap MaaKWinControlUnit) + +# Install test binary alongside the library +install(TARGETS test_kwin_screencap RUNTIME DESTINATION bin) + +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${maa_kwin_control_unit_src}) diff --git a/source/MaaKWinControlUnit/Input/UInputController.cpp b/source/MaaKWinControlUnit/Input/UInputController.cpp new file mode 100644 index 0000000000..ef58ffd767 --- /dev/null +++ b/source/MaaKWinControlUnit/Input/UInputController.cpp @@ -0,0 +1,528 @@ +#include "UInputController.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "MaaUtils/Logger.h" + +MAA_CTRL_UNIT_NS_BEGIN + +UInputController::UInputController() +{ +} + +UInputController::~UInputController() +{ + close(); +} + +bool UInputController::open(const std::filesystem::path& device_node, int screen_width, int screen_height) +{ + LogInfo << VAR(device_node) << VAR(screen_width) << VAR(screen_height); + + if (screen_width <= 0 || screen_height <= 0) { + LogError << "Invalid screen dimensions" << VAR(screen_width) << VAR(screen_height); + return false; + } + + std::unique_lock lock(mutex_); + + if (connected_) { + LogWarn << "Already connected, closing first"; + destroy_device(); + } + + device_node_ = device_node; + screen_width_ = screen_width; + screen_height_ = screen_height; + + if (!create_device()) { + LogError << "Failed to create uinput device"; + return false; + } + + connected_ = true; + pointer_down_ = false; + LogInfo << "UInput device created successfully"; + return true; +} + +void UInputController::close() +{ + std::unique_lock lock(mutex_); + + if (!connected_) { + return; + } + + destroy_device(); + connected_ = false; + pointer_down_ = false; + LogInfo << "UInput device closed"; +} + +bool UInputController::connected() const +{ + return connected_; +} + +bool UInputController::click(int x, int y) +{ + LogDebug << VAR(x) << VAR(y); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + if (!send_pointer_down(x, y)) { + LogError << "Failed to send pointer down"; + return false; + } + + // Small delay to simulate a real click + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + if (!send_pointer_up()) { + LogError << "Failed to send pointer up"; + return false; + } + + return true; +} + +bool UInputController::swipe(int x1, int y1, int x2, int y2, int duration) +{ + LogDebug << VAR(x1) << VAR(y1) << VAR(x2) << VAR(y2) << VAR(duration); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + constexpr int kSteps = 20; // Number of interpolation steps + + if (!send_pointer_down(x1, y1)) { + LogError << "Failed to send pointer down"; + return false; + } + + int step_duration = duration / kSteps; + if (step_duration < 1) { + step_duration = 1; + } + + for (int i = 1; i <= kSteps; ++i) { + float t = static_cast(i) / kSteps; + int cx = static_cast(std::round(x1 + (x2 - x1) * t)); + int cy = static_cast(std::round(y1 + (y2 - y1) * t)); + + if (!send_pointer_move(cx, cy)) { + LogError << "Failed to send pointer move at step" << i; + return false; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(step_duration)); + } + + if (!send_pointer_up()) { + LogError << "Failed to send pointer up"; + return false; + } + + return true; +} + +bool UInputController::touch_down(int contact, int x, int y, int pressure) +{ + LogDebug << VAR(contact) << VAR(x) << VAR(y) << VAR(pressure); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + // contact is ignored — we are a single-pointer absolute mouse + return send_pointer_down(x, y); +} + +bool UInputController::touch_move(int contact, int x, int y, int pressure) +{ + LogDebug << VAR(contact) << VAR(x) << VAR(y) << VAR(pressure); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + if (!pointer_down_) { + LogError << "Pointer is not down, cannot move"; + return false; + } + + return send_pointer_move(x, y); +} + +bool UInputController::touch_up(int contact) +{ + LogDebug << VAR(contact); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + if (!pointer_down_) { + LogError << "Pointer is not down"; + return false; + } + + return send_pointer_up(); +} + +bool UInputController::scroll(int dx, int dy) +{ + LogDebug << VAR(dx) << VAR(dy); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + struct input_event ev; + + if (dy != 0) { + std::memset(&ev, 0, sizeof(ev)); + ev.type = EV_REL; + ev.code = REL_WHEEL; + ev.value = -dy; // Negative for natural scrolling direction + if (write(fd_, &ev, sizeof(ev)) != sizeof(ev)) { + LogError << "Failed to write REL_WHEEL event" << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + } + + if (dx != 0) { + std::memset(&ev, 0, sizeof(ev)); + ev.type = EV_REL; + ev.code = REL_HWHEEL; + ev.value = dx; + if (write(fd_, &ev, sizeof(ev)) != sizeof(ev)) { + LogError << "Failed to write REL_HWHEEL event" << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + } + + if (!emit_syn()) { + LogError << "Failed to emit SYN_REPORT for scroll"; + return false; + } + + return true; +} + +bool UInputController::relative_move(int dx, int dy) +{ + LogDebug << VAR(dx) << VAR(dy); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + struct input_event ev; + + if (dx != 0) { + std::memset(&ev, 0, sizeof(ev)); + ev.type = EV_REL; + ev.code = REL_X; + ev.value = dx; + if (write(fd_, &ev, sizeof(ev)) != sizeof(ev)) { + LogError << "Failed to write REL_X event" << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + } + + if (dy != 0) { + std::memset(&ev, 0, sizeof(ev)); + ev.type = EV_REL; + ev.code = REL_Y; + ev.value = dy; + if (write(fd_, &ev, sizeof(ev)) != sizeof(ev)) { + LogError << "Failed to write REL_Y event" << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + } + + if (!emit_syn()) { + LogError << "Failed to emit SYN_REPORT for relative move"; + return false; + } + + return true; +} + +std::pair UInputController::screen_size() const +{ + return { screen_width_, screen_height_ }; +} + +bool UInputController::create_device() +{ + LogInfo << "Creating uinput device at" << VAR(device_node_); + + fd_ = ::open(device_node_.c_str(), O_WRONLY | O_NONBLOCK); + if (fd_ < 0) { + LogError << "Failed to open" << VAR(device_node_) << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + + // Enable event types + if (ioctl(fd_, UI_SET_EVBIT, EV_KEY) < 0) { + LogError << "Failed to set EV_KEY" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + if (ioctl(fd_, UI_SET_EVBIT, EV_ABS) < 0) { + LogError << "Failed to set EV_ABS" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + if (ioctl(fd_, UI_SET_EVBIT, EV_REL) < 0) { + LogWarn << "Failed to set EV_REL, relative events may not work" << VAR(errno) << VAR(std::strerror(errno)); + // Non-fatal, continue + } + if (ioctl(fd_, UI_SET_EVBIT, EV_SYN) < 0) { + LogError << "Failed to set EV_SYN" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + + // Register ONLY BTN_LEFT — this is a single-button absolute pointer (tablet). + // No BTN_TOUCH, no BTN_TOOL_FINGER, no BTN_RIGHT/MIDDLE. + // This ensures udev identifies it as ID_INPUT_TABLET, not ID_INPUT_TOUCHPAD. + if (ioctl(fd_, UI_SET_KEYBIT, BTN_LEFT) < 0) { + LogError << "Failed to set BTN_LEFT" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + + // Register ONLY ABS_X and ABS_Y — absolute positioning axes. + // No ABS_MT_* axes at all, so udev will NOT classify this as a touchscreen/touchpad. + if (ioctl(fd_, UI_SET_ABSBIT, ABS_X) < 0) { + LogError << "Failed to set ABS_X" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + if (ioctl(fd_, UI_SET_ABSBIT, ABS_Y) < 0) { + LogError << "Failed to set ABS_Y" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + + // Enable relative axes (for scroll and relative_move) + if (ioctl(fd_, UI_SET_RELBIT, REL_X) < 0) { + LogWarn << "Failed to set REL_X" << VAR(errno) << VAR(std::strerror(errno)); + } + if (ioctl(fd_, UI_SET_RELBIT, REL_Y) < 0) { + LogWarn << "Failed to set REL_Y" << VAR(errno) << VAR(std::strerror(errno)); + } + if (ioctl(fd_, UI_SET_RELBIT, REL_WHEEL) < 0) { + LogWarn << "Failed to set REL_WHEEL" << VAR(errno) << VAR(std::strerror(errno)); + } + if (ioctl(fd_, UI_SET_RELBIT, REL_HWHEEL) < 0) { + LogWarn << "Failed to set REL_HWHEEL" << VAR(errno) << VAR(std::strerror(errno)); + } + + // Configure the uinput device using the traditional uinput_user_dev struct. + struct uinput_user_dev udev; + std::memset(&udev, 0, sizeof(udev)); + std::strncpy(udev.name, "MaaFramework KWin Virtual Pointer", sizeof(udev.name) - 1); + udev.id.bustype = BUS_USB; + udev.id.vendor = 0x1234; + udev.id.product = 0x5678; + udev.id.version = 1; + + // Set ABS min/max for the two absolute axes we registered. + udev.absmin[ABS_X] = 0; + udev.absmax[ABS_X] = screen_width_ - 1; + udev.absfuzz[ABS_X] = 0; + udev.absflat[ABS_X] = 0; + + udev.absmin[ABS_Y] = 0; + udev.absmax[ABS_Y] = screen_height_ - 1; + udev.absfuzz[ABS_Y] = 0; + udev.absflat[ABS_Y] = 0; + + // Write the uinput_user_dev struct to the fd. + ssize_t written = write(fd_, &udev, sizeof(udev)); + if (written != static_cast(sizeof(udev))) { + LogError << "Failed to write uinput_user_dev" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + + // Create the device + if (ioctl(fd_, UI_DEV_CREATE) < 0) { + LogError << "Failed to create uinput device" << VAR(errno) << VAR(std::strerror(errno)); + ::close(fd_); + fd_ = -1; + return false; + } + + LogInfo << "UInput device created successfully"; + return true; +} + +bool UInputController::destroy_device() +{ + LogInfo << "Destroying uinput device"; + + if (fd_ < 0) { + return true; + } + + // Destroy the uinput device + if (ioctl(fd_, UI_DEV_DESTROY) < 0) { + LogWarn << "Failed to destroy uinput device" << VAR(errno) << VAR(std::strerror(errno)); + // Continue anyway + } + + ::close(fd_); + fd_ = -1; + + return true; +} + +bool UInputController::emit_abs(int code, int value) +{ + struct input_event ev; + std::memset(&ev, 0, sizeof(ev)); + ev.type = EV_ABS; + ev.code = code; + ev.value = value; + + if (write(fd_, &ev, sizeof(ev)) != sizeof(ev)) { + LogError << "Failed to write EV_ABS event" << VAR(code) << VAR(value) << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + return true; +} + +bool UInputController::emit_key(int code, int value) +{ + struct input_event ev; + std::memset(&ev, 0, sizeof(ev)); + ev.type = EV_KEY; + ev.code = code; + ev.value = value; + + if (write(fd_, &ev, sizeof(ev)) != sizeof(ev)) { + LogError << "Failed to write EV_KEY event" << VAR(code) << VAR(value) << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + return true; +} + +bool UInputController::emit_syn() +{ + struct input_event ev; + std::memset(&ev, 0, sizeof(ev)); + ev.type = EV_SYN; + ev.code = SYN_REPORT; + ev.value = 0; + + if (write(fd_, &ev, sizeof(ev)) != sizeof(ev)) { + LogError << "Failed to write SYN_REPORT" << VAR(errno) << VAR(std::strerror(errno)); + return false; + } + return true; +} + +bool UInputController::send_pointer_down(int x, int y) +{ + // Move to absolute position first + if (!emit_abs(ABS_X, x)) { + return false; + } + if (!emit_abs(ABS_Y, y)) { + return false; + } + // Press the left button + if (!emit_key(BTN_LEFT, 1)) { + return false; + } + if (!emit_syn()) { + return false; + } + + pointer_down_ = true; + return true; +} + +bool UInputController::send_pointer_move(int x, int y) +{ + // Move to new absolute position (button state unchanged) + if (!emit_abs(ABS_X, x)) { + return false; + } + if (!emit_abs(ABS_Y, y)) { + return false; + } + if (!emit_syn()) { + return false; + } + + return true; +} + +bool UInputController::send_pointer_up() +{ + // Release the left button + if (!emit_key(BTN_LEFT, 0)) { + return false; + } + if (!emit_syn()) { + return false; + } + + pointer_down_ = false; + return true; +} + +uint64_t UInputController::now_ms() +{ + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +MAA_CTRL_UNIT_NS_END diff --git a/source/MaaKWinControlUnit/Input/UInputController.h b/source/MaaKWinControlUnit/Input/UInputController.h new file mode 100644 index 0000000000..c6ad14ddb1 --- /dev/null +++ b/source/MaaKWinControlUnit/Input/UInputController.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "Common/Conf.h" + +MAA_CTRL_UNIT_NS_BEGIN + +class UInputController +{ +public: + UInputController(); + ~UInputController(); + + UInputController(const UInputController&) = delete; + UInputController& operator=(const UInputController&) = delete; + + bool open(const std::filesystem::path& device_node, int screen_width, int screen_height); + void close(); + bool connected() const; + + bool click(int x, int y); + bool swipe(int x1, int y1, int x2, int y2, int duration); + + bool touch_down(int contact, int x, int y, int pressure); + bool touch_move(int contact, int x, int y, int pressure); + bool touch_up(int contact); + + bool scroll(int dx, int dy); + bool relative_move(int dx, int dy); + + std::pair screen_size() const; + +private: + bool create_device(); + bool destroy_device(); + bool emit_abs(int code, int value); + bool emit_key(int code, int value); + bool emit_syn(); + + // Send absolute position + BTN_LEFT=1 + SYN + bool send_pointer_down(int x, int y); + // Send absolute position + SYN (button state unchanged) + bool send_pointer_move(int x, int y); + // Send BTN_LEFT=0 + SYN + bool send_pointer_up(); + + static uint64_t now_ms(); + + int fd_ = -1; + bool connected_ = false; + + int screen_width_ = 0; + int screen_height_ = 0; + + std::filesystem::path device_node_; + + // Track whether the pointer button is currently pressed + bool pointer_down_ = false; + + mutable std::mutex mutex_; +}; + +MAA_CTRL_UNIT_NS_END diff --git a/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp b/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp new file mode 100644 index 0000000000..d9bc7b7f5f --- /dev/null +++ b/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp @@ -0,0 +1,235 @@ +#include "KWinControlUnitMgr.h" + +#include +#include + +#include + +#include "Input/UInputController.h" +#include "Screencap/PipeWireScreencap.h" +#include "MaaUtils/Logger.h" +#include "MaaUtils/Platform.h" + +MAA_CTRL_UNIT_NS_BEGIN + +KWinControlUnitMgr::KWinControlUnitMgr(std::filesystem::path device_node, int screen_width, int screen_height) + : input_(std::make_unique()) + , m_screencap_(std::make_shared()) + , device_node_(std::move(device_node)) + , screen_width_(screen_width) + , screen_height_(screen_height) +{ + LogFunc << VAR(device_node_) << VAR(screen_width_) << VAR(screen_height_); + m_screencap_->set_screen_size(screen_width_, screen_height_); +} + +KWinControlUnitMgr::~KWinControlUnitMgr() +{ + LogFunc; +} + +bool KWinControlUnitMgr::connect() +{ + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } + + LogFunc << VAR(device_node_) << VAR(screen_width_) << VAR(screen_height_); + + // Open uinput input device + if (!input_->open(device_node_, screen_width_, screen_height_)) { + LogError << "Failed to open uinput device"; + return false; + } + + // NOTE: PipeWire screencap initialization is deferred to the first + // screencap() call (lazy initialization). We do NOT call open() here + // because: + // 1. D-Bus handshake (including the KDE auth dialog) should not be + // triggered during connect(), only when the user actually needs + // a screenshot. + // 2. PipeWire connection requires a file descriptor from the portal's + // OpenPipeWireRemote D-Bus method, which is only available after + // the portal Start call completes. + return true; +} + +bool KWinControlUnitMgr::connected() const +{ + if (!input_) { + return false; + } + return input_->connected(); +} + +bool KWinControlUnitMgr::request_uuid(std::string& uuid) +{ + std::error_code ec; + std::filesystem::path p(device_node_); + if (!std::filesystem::exists(p, ec)) { + return false; + } + + auto ftime = std::filesystem::last_write_time(p, ec); + if (ec) { + return false; + } + + auto stime = std::chrono::time_point_cast( + ftime - std::filesystem::file_time_type::clock::now() + std::chrono::system_clock::now()); + auto time = std::chrono::system_clock::to_time_t(stime); + uuid = std::to_string(time); + return true; +} + +MaaControllerFeature KWinControlUnitMgr::get_features() const +{ + return MaaControllerFeature_UseMouseDownAndUpInsteadOfClick | MaaControllerFeature_UseKeyboardDownAndUpInsteadOfClick; +} + +bool KWinControlUnitMgr::start_app(const std::string& intent) +{ + // TODO: Implement via D-Bus or system command + std::ignore = intent; + + return false; +} + +bool KWinControlUnitMgr::stop_app(const std::string& intent) +{ + // TODO: Implement via D-Bus or system command + std::ignore = intent; + + return false; +} + +bool KWinControlUnitMgr::screencap(cv::Mat& image) +{ + if (!m_screencap_) { + LogError << "m_screencap_ is nullptr"; + return false; + } + + // Delegate directly to PipeWireScreencap. + // PipeWireScreencap::screencap() will attempt to (re-)initialize if not connected. + return m_screencap_->screencap(image); +} + +bool KWinControlUnitMgr::click(int x, int y) +{ + LogError << "deprecated: get_features() returns MaaControllerFeature_UseMouseDownAndUpInsteadOfClick, " + "use touch_down/touch_up instead" + << VAR(x) << VAR(y); + return false; +} + +bool KWinControlUnitMgr::swipe(int x1, int y1, int x2, int y2, int duration) +{ + LogError << "deprecated: get_features() returns MaaControllerFeature_UseMouseDownAndUpInsteadOfClick, " + "use touch_down/touch_move/touch_up instead" + << VAR(x1) << VAR(y1) << VAR(x2) << VAR(y2) << VAR(duration); + return false; +} + +bool KWinControlUnitMgr::touch_down(int contact, int x, int y, int pressure) +{ + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } + + return input_->touch_down(contact, x, y, pressure); +} + +bool KWinControlUnitMgr::touch_move(int contact, int x, int y, int pressure) +{ + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } + + return input_->touch_move(contact, x, y, pressure); +} + +bool KWinControlUnitMgr::touch_up(int contact) +{ + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } + + return input_->touch_up(contact); +} + +bool KWinControlUnitMgr::click_key(int key) +{ + LogError << "deprecated: get_features() returns MaaControllerFeature_UseKeyboardDownAndUpInsteadOfClick, " + "use key_down/key_up instead" + << VAR(key); + return false; +} + +bool KWinControlUnitMgr::input_text(const std::string& text) +{ + // TODO: Implement keyboard text input via uinput + std::ignore = text; + + LogError << "input_text not yet implemented for KWin control unit"; + return false; +} + +bool KWinControlUnitMgr::key_down(int key) +{ + // TODO: Implement keyboard key down via uinput + std::ignore = key; + + LogError << "key_down not yet implemented for KWin control unit"; + return false; +} + +bool KWinControlUnitMgr::key_up(int key) +{ + // TODO: Implement keyboard key up via uinput + std::ignore = key; + + LogError << "key_up not yet implemented for KWin control unit"; + return false; +} + +bool KWinControlUnitMgr::relative_move(int dx, int dy) +{ + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } + + return input_->relative_move(dx, dy); +} + +bool KWinControlUnitMgr::scroll(int dx, int dy) +{ + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } + + return input_->scroll(dx, dy); +} + +bool KWinControlUnitMgr::inactive() +{ + return true; +} + +json::object KWinControlUnitMgr::get_info() const +{ + json::object info; + info["type"] = "kwin"; + info["device_node"] = path_to_utf8_string(device_node_); + info["screen_width"] = screen_width_; + info["screen_height"] = screen_height_; + return info; +} + +MAA_CTRL_UNIT_NS_END diff --git a/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.h b/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.h new file mode 100644 index 0000000000..92670f5232 --- /dev/null +++ b/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +#include "MaaControlUnit/ControlUnitAPI.h" +#include "MaaFramework/MaaDef.h" + +#include "Common/Conf.h" + +MAA_CTRL_UNIT_NS_BEGIN + +class UInputController; +class PipeWireScreencap; + +class KWinControlUnitMgr : public KWinControlUnitAPI +{ +public: + KWinControlUnitMgr(std::filesystem::path device_node, int screen_width, int screen_height); + virtual ~KWinControlUnitMgr() override; + +public: + virtual bool connect() override; + virtual bool connected() const override; + + virtual bool request_uuid(std::string& uuid) override; + virtual MaaControllerFeature get_features() const override; + + virtual bool start_app(const std::string& intent) override; + virtual bool stop_app(const std::string& intent) override; + + virtual bool screencap(cv::Mat& image) override; + + virtual bool click(int x, int y) override; + virtual bool swipe(int x1, int y1, int x2, int y2, int duration) override; + + virtual bool touch_down(int contact, int x, int y, int pressure) override; + virtual bool touch_move(int contact, int x, int y, int pressure) override; + virtual bool touch_up(int contact) override; + + virtual bool click_key(int key) override; + virtual bool input_text(const std::string& text) override; + + virtual bool key_down(int key) override; + virtual bool key_up(int key) override; + + virtual bool relative_move(int dx, int dy) override; + virtual bool scroll(int dx, int dy) override; + + virtual bool inactive() override; + + virtual json::object get_info() const override; + +private: + std::unique_ptr input_; + std::shared_ptr m_screencap_; + std::filesystem::path device_node_; + int screen_width_ = 0; + int screen_height_ = 0; +}; + +MAA_CTRL_UNIT_NS_END diff --git a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp new file mode 100644 index 0000000000..d89ff3957d --- /dev/null +++ b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp @@ -0,0 +1,1138 @@ +#include "PipeWireScreencap.h" + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "MaaUtils/Logger.h" + +MAA_CTRL_UNIT_NS_BEGIN + +// --------------------------------------------------------------------------- +// D-Bus constants for xdg-desktop-portal ScreenCast +// --------------------------------------------------------------------------- +static constexpr const char* kPortalBusName = "org.freedesktop.portal.Desktop"; +static constexpr const char* kPortalPath = "/org/freedesktop/portal/desktop"; +static constexpr const char* kPortalInterface = "org.freedesktop.portal.ScreenCast"; +static constexpr const char* kPortalRequestInterface = "org.freedesktop.portal.Request"; +static constexpr const char* kSessionInterface = "org.freedesktop.portal.Session"; + +/// Default timeout for D-Bus pending calls (milliseconds). +static constexpr int kDBusCallTimeoutMs = 10000; + +/// Timeout for OpenPipeWireRemote (ms). +static constexpr int kOpenPipeWireTimeoutMs = 5000; + +/// Timeout for waiting for the Start Response signal (seconds). +static constexpr int kStartResponseTimeoutSec = 30; + +/// D-Bus polling interval when waiting for signals (milliseconds). +static constexpr int kDBusPollIntervalMs = 100; + +// --------------------------------------------------------------------------- +// PipeWire buffer negotiation constants +// --------------------------------------------------------------------------- +/// Number of buffers to request (min / default / max) for SPA_PARAM_BUFFERS_buffers. +static constexpr int32_t kPWBufferCountMin = 2; +static constexpr int32_t kPWBufferCountDefault = 4; +static constexpr int32_t kPWBufferCountMax = 8; + +/// Number of memory blocks per buffer. +static constexpr int32_t kPWBufferBlocks = 1; + +/// Buffer alignment (bytes). +static constexpr int32_t kPWBufferAlign = 16; + +/// Bytes per pixel for BGRA format. +static constexpr int kBytesPerPixel = 4; + +// --------------------------------------------------------------------------- +// Helper: wait for a D-Bus Response signal on a given request path. +// +// Polls up to @p timeout_sec seconds, draining messages from the connection. +// Returns true if the Response signal was received, storing the response code +// in @p response. If @p extract_key is non-null, extracts the corresponding +// string value from the results dict into @p out_value. +// --------------------------------------------------------------------------- +static bool dbus_wait_for_signal(DBusConnection* conn, const std::string& request_path, + uint32_t& response, const char* extract_key, + std::string& out_value, int timeout_sec = kStartResponseTimeoutSec) +{ + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_sec); + + while (std::chrono::steady_clock::now() < deadline) { + dbus_connection_flush(conn); + + // Non-blocking read with short timeout to allow deadline expiry + if (!dbus_connection_read_write(conn, kDBusPollIntervalMs)) { + continue; + } + + DBusMessage* msg = dbus_connection_pop_message(conn); + if (!msg) { + continue; + } + + if (dbus_message_is_signal(msg, kPortalRequestInterface, "Response")) { + const char* path = dbus_message_get_path(msg); + if (path && request_path == path) { + DBusMessageIter args; + if (!dbus_message_iter_init(msg, &args)) { + dbus_message_unref(msg); + continue; + } + + if (dbus_message_iter_get_arg_type(&args) != DBUS_TYPE_UINT32) { + dbus_message_unref(msg); + continue; + } + dbus_message_iter_get_basic(&args, &response); + + // Parse results dict if needed + if (extract_key && extract_key[0] != '\0') { + dbus_message_iter_next(&args); + if (dbus_message_iter_get_arg_type(&args) == DBUS_TYPE_ARRAY) { + DBusMessageIter dict_iter; + dbus_message_iter_recurse(&args, &dict_iter); + + while (dbus_message_iter_get_arg_type(&dict_iter) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter entry; + dbus_message_iter_recurse(&dict_iter, &entry); + + const char* key = nullptr; + dbus_message_iter_get_basic(&entry, &key); + dbus_message_iter_next(&entry); + + DBusMessageIter value; + dbus_message_iter_recurse(&entry, &value); + + if (key && strcmp(key, extract_key) == 0) { + int value_type = dbus_message_iter_get_arg_type(&value); + if (value_type == DBUS_TYPE_STRING || value_type == DBUS_TYPE_OBJECT_PATH) { + const char* val = nullptr; + dbus_message_iter_get_basic(&value, &val); + if (val) { + out_value = val; + } + } + break; + } + + dbus_message_iter_next(&dict_iter); + } + } + } + + dbus_message_unref(msg); + return true; + } + } + else if (dbus_message_is_signal(msg, kSessionInterface, "Closed")) { + dbus_message_unref(msg); + return false; + } + + dbus_message_unref(msg); + } + + LogError << "Timeout waiting for D-Bus Response signal on" << request_path; + return false; +} + +// --------------------------------------------------------------------------- +// Helper: send a D-Bus method call via DBusPendingCall and wait for reply. +// +// Uses DBusPendingCall (not send_with_reply_and_block) to avoid consuming +// unrelated messages during the internal read loop. +// --------------------------------------------------------------------------- +static DBusMessage* dbus_call_pending(DBusConnection* conn, const char* method_name, + DBusMessage* msg, int timeout_ms = kDBusCallTimeoutMs) +{ + DBusPendingCall* pending = nullptr; + if (!dbus_connection_send_with_reply(conn, msg, &pending, timeout_ms)) { + LogError << "D-Bus call" << method_name << ": send_with_reply failed (OOM)"; + return nullptr; + } + dbus_connection_flush(conn); + + if (!pending) { + LogError << "D-Bus call" << method_name << ": pending is null (disconnected)"; + return nullptr; + } + + dbus_pending_call_block(pending); + DBusMessage* reply = dbus_pending_call_steal_reply(pending); + dbus_pending_call_unref(pending); + + if (!reply) { + LogError << "D-Bus call" << method_name << ": reply is null (timeout)"; + return nullptr; + } + + if (dbus_message_get_type(reply) == DBUS_MESSAGE_TYPE_ERROR) { + const char* err_name = dbus_message_get_error_name(reply); + LogError << "D-Bus call" << method_name << "returned error: " << (err_name ? err_name : "(unknown)"); + dbus_message_unref(reply); + return nullptr; + } + + return reply; +} + +// --------------------------------------------------------------------------- +// D-Bus dictionary helpers (a{sv} entries) +// --------------------------------------------------------------------------- +static void dbus_append_dict_entry_string(DBusMessageIter* dict, const char* key, const char* value) +{ + DBusMessageIter entry, variant; + dbus_message_iter_open_container(dict, DBUS_TYPE_DICT_ENTRY, nullptr, &entry); + dbus_message_iter_append_basic(&entry, DBUS_TYPE_STRING, &key); + dbus_message_iter_open_container(&entry, DBUS_TYPE_VARIANT, "s", &variant); + dbus_message_iter_append_basic(&variant, DBUS_TYPE_STRING, &value); + dbus_message_iter_close_container(&entry, &variant); + dbus_message_iter_close_container(dict, &entry); +} + +static void dbus_append_dict_entry_uint32(DBusMessageIter* dict, const char* key, uint32_t value) +{ + DBusMessageIter entry, variant; + dbus_message_iter_open_container(dict, DBUS_TYPE_DICT_ENTRY, nullptr, &entry); + dbus_message_iter_append_basic(&entry, DBUS_TYPE_STRING, &key); + dbus_message_iter_open_container(&entry, DBUS_TYPE_VARIANT, "u", &variant); + dbus_message_iter_append_basic(&variant, DBUS_TYPE_UINT32, &value); + dbus_message_iter_close_container(&entry, &variant); + dbus_message_iter_close_container(dict, &entry); +} + +static void dbus_append_dict_entry_bool(DBusMessageIter* dict, const char* key, bool value) +{ + uint32_t dbus_bool_val = value ? 1 : 0; + DBusMessageIter entry, variant; + dbus_message_iter_open_container(dict, DBUS_TYPE_DICT_ENTRY, nullptr, &entry); + dbus_message_iter_append_basic(&entry, DBUS_TYPE_STRING, &key); + dbus_message_iter_open_container(&entry, DBUS_TYPE_VARIANT, "b", &variant); + dbus_message_iter_append_basic(&variant, DBUS_TYPE_BOOLEAN, &dbus_bool_val); + dbus_message_iter_close_container(&entry, &variant); + dbus_message_iter_close_container(dict, &entry); +} + +// =========================================================================== +// PipeWireScreencap implementation +// =========================================================================== + +PipeWireScreencap::PipeWireScreencap() +{ + // PipeWire global init is safe to call multiple times (uses an internal refcount). + ::pw_init(nullptr, nullptr); +} + +PipeWireScreencap::~PipeWireScreencap() +{ + close(); +} + +void PipeWireScreencap::set_screen_size(int width, int height) +{ + screen_width_ = width; + screen_height_ = height; +} + +void PipeWireScreencap::close_internal() +{ + // Tear down PipeWire objects in reverse order of creation. + // This is safe even if some pointers are null (checks before use). + + if (pw_thread_loop_) { + pw_thread_loop_stop(pw_thread_loop_); + } + + if (pw_stream_) { + spa_hook_remove(&stream_hook_); + pw_stream_destroy(pw_stream_); + pw_stream_ = nullptr; + } + + if (pw_core_) { + pw_core_disconnect(pw_core_); + pw_core_ = nullptr; + } + + if (pw_context_) { + pw_context_destroy(pw_context_); + pw_context_ = nullptr; + } + + if (pw_thread_loop_) { + pw_thread_loop_destroy(pw_thread_loop_); + pw_thread_loop_ = nullptr; + } + pw_loop_ = nullptr; + + delete stream_events_; + stream_events_ = nullptr; + delete core_events_; + core_events_ = nullptr; + + // Reset all state + connected_ = false; + open_attempted_ = false; + stream_params_received_ = false; + frame_available_ = false; + frame_width_ = 0; + frame_height_ = 0; + pipewire_node_id_ = 0; + pipewire_fd_ = -1; + dbus_session_handle_.clear(); + + { + std::lock_guard lock(frame_mutex_); + latest_frame_ = cv::Mat(); + } +} + +bool PipeWireScreencap::open() +{ + if (connected_) { + return true; + } + + // Clean up any stale state from a previous failed session + close_internal(); + + // Step 1: D-Bus handshake with xdg-desktop-portal. + // This includes CreateSession, SelectSources, and Start (which shows + // the KDE authorization dialog). Timeout is kStartResponseTimeoutSec. + if (!dbus_create_session() || !dbus_select_sources() || !dbus_start_stream()) { + // dbus_* methods already logged errors + close_internal(); + return false; + } + + // Step 2: Initialize PipeWire (connect core, create stream, connect) + if (!pw_init()) { + close_internal(); + return false; + } + + if (!pw_create_stream()) { + close_internal(); + return false; + } + + if (!pw_connect_stream(pipewire_node_id_)) { + close_internal(); + return false; + } + + connected_ = true; + return true; +} + +void PipeWireScreencap::close() +{ + close_internal(); +} + +bool PipeWireScreencap::connected() const +{ + return connected_; +} + +bool PipeWireScreencap::screencap(cv::Mat& image) +{ + // Lazy init: try once on first screencap() call + if (!connected_ && !open_attempted_) { + open_attempted_ = true; + if (!open()) { + return false; + } + } + + if (!connected_) { + return false; + } + + // Wait for the first frame with a timeout. + // Subsequent calls return the cached frame immediately without waiting. + std::unique_lock lock(frame_mutex_); + if (latest_frame_.empty()) { + if (!frame_cv_.wait_for(lock, std::chrono::seconds(2), + [this]() { return !latest_frame_.empty(); })) { + LogError << "Timeout waiting for first PipeWire frame"; + return false; + } + } + + latest_frame_.copyTo(image); + return true; +} + +// =========================================================================== +// D-Bus implementation +// =========================================================================== + +bool PipeWireScreencap::dbus_open_pipewire_remote(DBusConnection* conn) +{ + // OpenPipeWireRemote(session_handle, a{sv}) -> (h) + DBusMessage* msg = dbus_message_new_method_call( + kPortalBusName, kPortalPath, kPortalInterface, "OpenPipeWireRemote"); + if (!msg) { + LogError << "Failed to create OpenPipeWireRemote message"; + return false; + } + + DBusMessageIter iter, dict; + dbus_message_iter_init_append(msg, &iter); + + const char* session_path = dbus_session_handle_.c_str(); + dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &session_path); + + dbus_message_iter_open_container(&iter, DBUS_TYPE_ARRAY, "{sv}", &dict); + dbus_message_iter_close_container(&iter, &dict); + + DBusPendingCall* pending = nullptr; + if (!dbus_connection_send_with_reply(conn, msg, &pending, kOpenPipeWireTimeoutMs)) { + LogError << "OpenPipeWireRemote: send_with_reply failed (OOM)"; + dbus_message_unref(msg); + return false; + } + dbus_connection_flush(conn); + dbus_message_unref(msg); + + if (!pending) { + LogError << "OpenPipeWireRemote: pending is null (disconnected)"; + return false; + } + + dbus_pending_call_block(pending); + DBusMessage* reply = dbus_pending_call_steal_reply(pending); + dbus_pending_call_unref(pending); + + if (!reply) { + LogError << "OpenPipeWireRemote: reply is null (timeout)"; + return false; + } + + if (dbus_message_get_type(reply) == DBUS_MESSAGE_TYPE_ERROR) { + const char* err_name = dbus_message_get_error_name(reply); + LogError << "OpenPipeWireRemote error: " << (err_name ? err_name : "(unknown)"); + dbus_message_unref(reply); + return false; + } + + // Extract Unix FD — must dup() before unref as libdbus closes the FD on free + DBusError err; + dbus_error_init(&err); + + int raw_fd = -1; + if (!dbus_message_get_args(reply, &err, + DBUS_TYPE_UNIX_FD, &raw_fd, + DBUS_TYPE_INVALID)) { + LogError << "OpenPipeWireRemote: no FD in reply: " << (err.message ? err.message : ""); + dbus_error_free(&err); + dbus_message_unref(reply); + return false; + } + dbus_error_free(&err); + + if (raw_fd >= 0) { + pipewire_fd_ = dup(raw_fd); + if (pipewire_fd_ < 0) { + LogError << "Failed to dup PipeWire FD:" << strerror(errno); + } + } + + dbus_message_unref(reply); + + if (pipewire_fd_ < 0) { + LogError << "OpenPipeWireRemote: invalid FD"; + return false; + } + + return true; +} + +// --------------------------------------------------------------------------- +// dbus_create_session: calls org.freedesktop.portal.ScreenCast.CreateSession +// --------------------------------------------------------------------------- +bool PipeWireScreencap::dbus_create_session() +{ + DBusError err; + dbus_error_init(&err); + DBusConnection* conn = dbus_bus_get(DBUS_BUS_SESSION, &err); + if (!conn) { + LogError << "Failed to connect to session bus:" << err.message; + dbus_error_free(&err); + return false; + } + + DBusMessage* msg = dbus_message_new_method_call( + kPortalBusName, kPortalPath, kPortalInterface, "CreateSession"); + if (!msg) { + LogError << "Failed to create CreateSession message"; + return false; + } + + DBusMessageIter iter, dict; + dbus_message_iter_init_append(msg, &iter); + dbus_message_iter_open_container(&iter, DBUS_TYPE_ARRAY, "{sv}", &dict); + // session_handle_token is REQUIRED by xdg-desktop-portal + dbus_append_dict_entry_string(&dict, "session_handle_token", "maa_session"); + dbus_message_iter_close_container(&iter, &dict); + + DBusMessage* reply = dbus_call_pending(conn, "CreateSession", msg); + dbus_message_unref(msg); + if (!reply) { + return false; + } + + // Parse reply: (o request_path) + DBusMessageIter args; + std::string create_request_path; + if (dbus_message_iter_init(reply, &args) && + dbus_message_iter_get_arg_type(&args) == DBUS_TYPE_OBJECT_PATH) { + const char* p = nullptr; + dbus_message_iter_get_basic(&args, &p); + if (p) create_request_path = p; + } + + if (create_request_path.empty()) { + LogError << "CreateSession: missing request path"; + dbus_message_unref(reply); + return false; + } + + // Wait for Response signal with session_handle + uint32_t response_code = 0; + std::string session_handle; + if (!dbus_wait_for_signal(conn, create_request_path, response_code, + "session_handle", session_handle)) { + LogError << "CreateSession: no response signal"; + dbus_message_unref(reply); + return false; + } + + if (response_code != 0) { + LogError << "CreateSession failed, code=" << response_code; + dbus_message_unref(reply); + return false; + } + + if (session_handle.empty()) { + LogError << "CreateSession: missing session_handle"; + dbus_message_unref(reply); + return false; + } + + dbus_session_handle_ = session_handle; + dbus_message_unref(reply); + return true; +} + +// --------------------------------------------------------------------------- +// dbus_select_sources: calls org.freedesktop.portal.ScreenCast.SelectSources +// --------------------------------------------------------------------------- +bool PipeWireScreencap::dbus_select_sources() +{ + DBusError err; + dbus_error_init(&err); + DBusConnection* conn = dbus_bus_get(DBUS_BUS_SESSION, &err); + if (!conn) { + LogError << "Failed to connect to session bus:" << err.message; + dbus_error_free(&err); + return false; + } + + DBusMessage* msg = dbus_message_new_method_call( + kPortalBusName, kPortalPath, kPortalInterface, "SelectSources"); + if (!msg) { + LogError << "Failed to create SelectSources message"; + return false; + } + + DBusMessageIter iter, dict; + dbus_message_iter_init_append(msg, &iter); + + const char* session_path = dbus_session_handle_.c_str(); + dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &session_path); + + dbus_message_iter_open_container(&iter, DBUS_TYPE_ARRAY, "{sv}", &dict); + dbus_append_dict_entry_string(&dict, "handle_token", "maa_select"); + dbus_append_dict_entry_uint32(&dict, "types", 1); // MONITOR + dbus_append_dict_entry_bool(&dict, "multiple", false); + dbus_message_iter_close_container(&iter, &dict); + + DBusMessage* reply = dbus_call_pending(conn, "SelectSources", msg); + dbus_message_unref(msg); + if (!reply) { + return false; + } + + DBusMessageIter args; + std::string select_request_path; + if (dbus_message_iter_init(reply, &args) && + dbus_message_iter_get_arg_type(&args) == DBUS_TYPE_OBJECT_PATH) { + const char* p = nullptr; + dbus_message_iter_get_basic(&args, &p); + if (p) select_request_path = p; + } + + if (select_request_path.empty()) { + LogError << "SelectSources: missing request path"; + dbus_message_unref(reply); + return false; + } + + uint32_t select_response = 0; + std::string unused; + if (!dbus_wait_for_signal(conn, select_request_path, select_response, nullptr, unused)) { + LogError << "SelectSources: no response signal"; + dbus_message_unref(reply); + return false; + } + + if (select_response != 0) { + LogError << "SelectSources failed, code=" << select_response; + dbus_message_unref(reply); + return false; + } + + dbus_message_unref(reply); + return true; +} + +// --------------------------------------------------------------------------- +// dbus_start_stream: calls org.freedesktop.portal.ScreenCast.Start and +// parses the response for the PipeWire node_id. +// --------------------------------------------------------------------------- +bool PipeWireScreencap::dbus_start_stream() +{ + DBusError err; + dbus_error_init(&err); + DBusConnection* conn = dbus_bus_get(DBUS_BUS_SESSION, &err); + if (!conn) { + LogError << "Failed to connect to session bus:" << err.message; + dbus_error_free(&err); + return false; + } + + DBusMessage* msg = dbus_message_new_method_call( + kPortalBusName, kPortalPath, kPortalInterface, "Start"); + if (!msg) { + LogError << "Failed to create Start message"; + return false; + } + + DBusMessageIter iter, dict; + dbus_message_iter_init_append(msg, &iter); + + const char* session_path = dbus_session_handle_.c_str(); + dbus_message_iter_append_basic(&iter, DBUS_TYPE_OBJECT_PATH, &session_path); + + const char* parent_window = ""; + dbus_message_iter_append_basic(&iter, DBUS_TYPE_STRING, &parent_window); + + dbus_message_iter_open_container(&iter, DBUS_TYPE_ARRAY, "{sv}", &dict); + dbus_append_dict_entry_string(&dict, "handle_token", "maa_start"); + dbus_message_iter_close_container(&iter, &dict); + + DBusMessage* reply = dbus_call_pending(conn, "Start", msg); + dbus_message_unref(msg); + if (!reply) { + return false; + } + + DBusMessageIter args; + std::string start_request_path; + if (dbus_message_iter_init(reply, &args) && + dbus_message_iter_get_arg_type(&args) == DBUS_TYPE_OBJECT_PATH) { + const char* p = nullptr; + dbus_message_iter_get_basic(&args, &p); + if (p) start_request_path = p; + } + + if (start_request_path.empty()) { + LogError << "Start: missing request path"; + dbus_message_unref(reply); + return false; + } + + // Add match rule so the D-Bus daemon delivers the Response signal + dbus_error_init(&err); + std::string match_rule = "type='signal',interface='" + std::string(kPortalRequestInterface) + + "',path='" + start_request_path + "'"; + dbus_bus_add_match(conn, match_rule.c_str(), &err); + if (dbus_error_is_set(&err)) { + dbus_error_free(&err); + } + + // Blocking loop waiting for user to authorise screen sharing + bool got_start_response = false; + uint32_t response_code = 0; + pipewire_node_id_ = 0; + + { + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(kStartResponseTimeoutSec); + + while (!got_start_response && std::chrono::steady_clock::now() < deadline) { + dbus_connection_read_write(conn, kDBusPollIntervalMs); + + DBusMessage* sig_msg = nullptr; + while ((sig_msg = dbus_connection_pop_message(conn)) != nullptr) { + if (dbus_message_is_signal(sig_msg, kPortalRequestInterface, "Response") && + start_request_path == dbus_message_get_path(sig_msg)) { + + DBusMessageIter sig_args; + dbus_message_iter_init(sig_msg, &sig_args); + + if (dbus_message_iter_get_arg_type(&sig_args) == DBUS_TYPE_UINT32) { + dbus_message_iter_get_basic(&sig_args, &response_code); + } + + // Parse results dict for "streams" -> node_id + dbus_message_iter_next(&sig_args); + if (dbus_message_iter_get_arg_type(&sig_args) == DBUS_TYPE_ARRAY) { + DBusMessageIter dict_iter; + dbus_message_iter_recurse(&sig_args, &dict_iter); + + while (dbus_message_iter_get_arg_type(&dict_iter) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter entry; + dbus_message_iter_recurse(&dict_iter, &entry); + + const char* key = nullptr; + dbus_message_iter_get_basic(&entry, &key); + dbus_message_iter_next(&entry); + + DBusMessageIter value; + dbus_message_iter_recurse(&entry, &value); + + if (key && strcmp(key, "streams") == 0 && + dbus_message_iter_get_arg_type(&value) == DBUS_TYPE_ARRAY) { + + DBusMessageIter streams_array; + dbus_message_iter_recurse(&value, &streams_array); + + if (dbus_message_iter_get_arg_type(&streams_array) == DBUS_TYPE_STRUCT) { + DBusMessageIter stream_struct; + dbus_message_iter_recurse(&streams_array, &stream_struct); + + if (dbus_message_iter_get_arg_type(&stream_struct) == DBUS_TYPE_UINT32) { + uint32_t node_id = 0; + dbus_message_iter_get_basic(&stream_struct, &node_id); + if (node_id != 0) { + pipewire_node_id_ = node_id; + } + } + } + } + + dbus_message_iter_next(&dict_iter); + } + } + + got_start_response = true; + } + else if (dbus_message_is_signal(sig_msg, kSessionInterface, "Closed")) { + dbus_message_unref(sig_msg); + dbus_bus_remove_match(conn, match_rule.c_str(), nullptr); + dbus_message_unref(reply); + return false; + } + + dbus_message_unref(sig_msg); + } + } + } + + dbus_bus_remove_match(conn, match_rule.c_str(), nullptr); + + if (!got_start_response) { + LogError << "Start timed out after" << kStartResponseTimeoutSec << "s (auth dialog not accepted?)"; + dbus_message_unref(reply); + return false; + } + + if (response_code != 0) { + LogError << "Start rejected by user, code=" << response_code; + dbus_message_unref(reply); + return false; + } + + if (pipewire_node_id_ == 0) { + LogError << "Start: missing node_id"; + dbus_message_unref(reply); + return false; + } + + dbus_message_unref(reply); + + // ---- OpenPipeWireRemote after Start succeeded ---- + if (!dbus_open_pipewire_remote(conn)) { + LogError << "OpenPipeWireRemote failed"; + return false; + } + + return true; +} + +// =========================================================================== +// PipeWire implementation +// =========================================================================== + +bool PipeWireScreencap::pw_init() +{ + // Create pw_thread_loop (standard event loop with integrated bg thread) + pw_thread_loop_ = pw_thread_loop_new("MaaScreencap", nullptr); + if (!pw_thread_loop_) { + LogError << "Failed to create PipeWire thread loop"; + return false; + } + + pw_loop_ = pw_thread_loop_get_loop(pw_thread_loop_); + + pw_context_ = pw_context_new(pw_loop_, nullptr, 0); + if (!pw_context_) { + LogError << "Failed to create PipeWire context"; + close_internal(); + return false; + } + + if (pipewire_fd_ < 0) { + LogError << "Invalid PipeWire FD (missing OpenPipeWireRemote?)"; + close_internal(); + return false; + } + + // Connect to KWin's private PipeWire instance via the portal FD + pw_core_ = pw_context_connect_fd(pw_context_, pipewire_fd_, nullptr, 0); + if (!pw_core_) { + LogError << "Failed to connect via portal PipeWire FD"; + close_internal(); + return false; + } + + // pw_context_connect_fd takes ownership of pipewire_fd_ + pipewire_fd_ = -1; + + return true; +} + +bool PipeWireScreencap::pw_create_stream() +{ + struct pw_properties* props = pw_properties_new( + PW_KEY_MEDIA_TYPE, "Video", + PW_KEY_MEDIA_CATEGORY, "Capture", + PW_KEY_MEDIA_ROLE, "Screen", + nullptr); + + // Use pw_stream_new (not pw_stream_new_simple) to avoid exception + // unwinding ABI incompatibility between clang/libc++ and PipeWire's glibc. + pw_stream_ = pw_stream_new(pw_core_, "MaaFramework Screencap", props); + if (!pw_stream_) { + LogError << "Failed to create PipeWire stream: " << strerror(errno); + if (props) pw_properties_free(props); + return false; + } + + // Set up event listeners + spa_zero(stream_hook_); + stream_events_ = new struct pw_stream_events(); + spa_zero(*stream_events_); + stream_events_->version = PW_VERSION_STREAM_EVENTS; + stream_events_->state_changed = reinterpret_caststate_changed)>( + pw_on_stream_state_changed); + stream_events_->param_changed = pw_on_stream_param_changed; + stream_events_->process = pw_on_stream_process; + pw_stream_add_listener(pw_stream_, &stream_hook_, stream_events_, this); + + return true; +} + +bool PipeWireScreencap::pw_connect_stream(uint32_t node_id) +{ + // Build format negotiation parameters: accept any resolution/framerate + uint8_t buffer[4096]; + struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); + struct spa_video_info_raw video_info = { + .format = SPA_VIDEO_FORMAT_BGRA, + .size = SPA_RECTANGLE(0, 0), + .framerate = { 0, 1 }, + }; + const struct spa_pod* params[1]; + params[0] = spa_format_video_raw_build(&b, SPA_PARAM_EnumFormat, &video_info); + + constexpr auto stream_flags = static_cast( + PW_STREAM_FLAG_AUTOCONNECT + | PW_STREAM_FLAG_DONT_RECONNECT + | PW_STREAM_FLAG_MAP_BUFFERS); + + // Start the bg thread BEFORE pw_stream_connect + if (pw_thread_loop_start(pw_thread_loop_) < 0) { + LogError << "Failed to start pw_thread_loop"; + return false; + } + + pw_thread_loop_lock(pw_thread_loop_); + int ret = pw_stream_connect(pw_stream_, PW_DIRECTION_INPUT, node_id, + stream_flags, params, 1); + pw_thread_loop_unlock(pw_thread_loop_); + + if (ret < 0) { + LogError << "Failed to connect PipeWire stream to node" << node_id; + return false; + } + + return true; +} + +// =========================================================================== +// PipeWire callbacks +// =========================================================================== + +void PipeWireScreencap::pw_on_core_error(void* data, uint32_t id, int seq, int res, const char* message) +{ + auto* self = static_cast(data); + LogError << "PipeWire core error: id=" << id << " seq=" << seq + << " res=" << res << " msg=" << (message ? message : ""); + + if (id == PW_ID_CORE && res == -EPIPE) { + self->connected_ = false; + } +} + +void PipeWireScreencap::pw_on_stream_state_changed(void* data, int old_state, int new_state, const char* error) +{ + auto* self = static_cast(data); + (void)self; + (void)old_state; + + if (new_state < 0) { + LogError << "Stream error: " << (error ? error : "(unknown)"); + } + else if (new_state == 3 /* PW_STREAM_STATE_STREAMING */ && error) { + LogWarn << "Stream error: " << error; + } +} + +void PipeWireScreencap::pw_on_stream_param_changed(void* data, uint32_t id, const struct spa_pod* param) +{ + auto* self = static_cast(data); + + if (!param || id != SPA_PARAM_Format) { + return; + } + + // Extract width/height from spa_pod using manual property iteration. + // spa_format_video_raw_parse can fail on portal-private PW instances. + uint32_t width = static_cast(self->screen_width_); + uint32_t height = static_cast(self->screen_height_); + + if (param) { + const struct spa_pod_prop* prop; + SPA_POD_OBJECT_FOREACH((struct spa_pod_object*)param, prop) { + if (prop->key == SPA_FORMAT_VIDEO_size) { + const struct spa_pod* val = &prop->value; + if (spa_pod_is_rectangle(val)) { + struct spa_rectangle* rect = (struct spa_rectangle*)SPA_POD_BODY(val); + if (rect->width > 0 && rect->height > 0) { + width = rect->width; + height = rect->height; + } + } + } + } + } + + self->frame_width_ = static_cast(width); + self->frame_height_ = static_cast(height); + + // Reply with SPA_PARAM_Buffers to complete format negotiation + if (self->pw_stream_) { + uint8_t pod_buffer[1024]; + struct spa_pod_builder b = SPA_POD_BUILDER_INIT(pod_buffer, sizeof(pod_buffer)); + const struct spa_pod* buf_params[1]; + + buf_params[0] = (const struct spa_pod*)spa_pod_builder_add_object(&b, + SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers, + SPA_PARAM_BUFFERS_buffers, SPA_POD_CHOICE_RANGE_Int( + kPWBufferCountDefault, kPWBufferCountMin, kPWBufferCountMax), + SPA_PARAM_BUFFERS_blocks, SPA_POD_Int(kPWBufferBlocks), + SPA_PARAM_BUFFERS_size, SPA_POD_Int(static_cast(width * height * kBytesPerPixel)), + SPA_PARAM_BUFFERS_stride, SPA_POD_Int(static_cast(width * kBytesPerPixel)), + SPA_PARAM_BUFFERS_align, SPA_POD_Int(kPWBufferAlign)); + + if (pw_stream_update_params(self->pw_stream_, buf_params, 1) < 0) { + LogError << "pw_stream_update_params failed"; + } + + pw_stream_set_active(self->pw_stream_, true); + } +} + +void PipeWireScreencap::pw_on_stream_process(void* data) +{ + auto* self = static_cast(data); + + struct pw_buffer* buf = pw_stream_dequeue_buffer(self->pw_stream_); + if (!buf) { + return; + } + + struct spa_buffer* spa_buf = buf->buffer; + if (!spa_buf || spa_buf->n_datas < 1) { + pw_stream_queue_buffer(self->pw_stream_, buf); + return; + } + + cv::Mat frame; + if (self->process_frame(spa_buf, frame)) { + { + std::lock_guard lock(self->frame_mutex_); + self->latest_frame_ = std::move(frame); + self->frame_available_ = true; + } + self->frame_cv_.notify_one(); + } + + pw_stream_queue_buffer(self->pw_stream_, buf); +} + +// =========================================================================== +// Frame processing +// =========================================================================== + +bool PipeWireScreencap::process_frame(const struct spa_buffer* spa_buf, cv::Mat& out_image) +{ + if (!spa_buf || spa_buf->n_datas < 1) { + return false; + } + + const struct spa_data& data = spa_buf->datas[0]; + if (!data.chunk || data.chunk->size == 0) { + return false; + } + + // Obtain a CPU-accessible pointer to the buffer data + void* mapped_ptr = data.data; + bool need_unmap = false; + size_t map_size = 0; + + if (!mapped_ptr) { + if (data.type == SPA_DATA_MemFd && data.fd >= 0) { + map_size = data.maxsize > 0 ? data.maxsize : data.chunk->size; + if (map_size > 0) { + mapped_ptr = mmap(nullptr, map_size, PROT_READ, MAP_PRIVATE, data.fd, 0); + if (mapped_ptr == MAP_FAILED) { + LogError << "mmap of PipeWire buffer failed: " << strerror(errno); + return false; + } + need_unmap = true; + } + } + else if (data.type == SPA_DATA_DmaBuf) { + LogError << "DMABUF not supported (need EGL import)"; + return false; + } + else { + LogError << "Unsupported buffer type: " << data.type; + return false; + } + } + + if (!mapped_ptr) { + return false; + } + + // Determine actual frame dimensions + int actual_width = frame_width_; + int actual_height = frame_height_; + + if (actual_width <= 0 || actual_height <= 0) { + uint32_t stride = data.chunk->stride; + size_t data_size = data.chunk->size; + + if (stride > 0 && data_size > 0) { + actual_width = infer_dimension_from_stride(stride, data_size); + if (actual_width > 0) { + actual_height = static_cast(data_size / stride); + } + } + } + + // Last resort: configured screen dimensions + if (actual_width <= 0 || actual_height <= 0) { + actual_width = screen_width_; + actual_height = screen_height_; + } + + if (actual_width <= 0 || actual_height <= 0) { + LogError << "Unable to determine frame dimensions"; + if (need_unmap) { + munmap(mapped_ptr, map_size); + } + return false; + } + + uint32_t stride = data.chunk->stride; + if (stride == 0) { + stride = static_cast(actual_width * kBytesPerPixel); + } + + // Create OpenCV Mat header wrapping the buffer (no copy until cvtColor) + cv::Mat raw(actual_height, actual_width, CV_8UC4, mapped_ptr, stride); + if (raw.empty()) { + if (need_unmap) { + munmap(mapped_ptr, map_size); + } + return false; + } + + // Convert BGRA -> BGR (this copies the data) + cv::Mat bgr_frame; + cv::cvtColor(raw, bgr_frame, cv::COLOR_BGRA2BGR); + + if (need_unmap) { + munmap(mapped_ptr, map_size); + } + + // Scale to target dimensions if needed + if (bgr_frame.size().width != screen_width_ || bgr_frame.size().height != screen_height_) { + cv::resize(bgr_frame, out_image, cv::Size(screen_width_, screen_height_)); + } + else { + out_image = std::move(bgr_frame); + } + + return true; +} + +int PipeWireScreencap::infer_dimension_from_stride(uint32_t stride, size_t /*data_size*/) const +{ + // For BGRA, each pixel is 4 bytes, so width = stride / 4 + if (stride > 0) { + return static_cast(stride / kBytesPerPixel); + } + return 0; +} + +MAA_CTRL_UNIT_NS_END diff --git a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h new file mode 100644 index 0000000000..c9af1e774b --- /dev/null +++ b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include + +#include "Common/Conf.h" + +/* Forward-declare PipeWire types (they are C types, not in any C++ namespace) */ +struct pw_thread_loop; +struct pw_loop; +struct pw_context; +struct pw_core; +struct pw_stream; +struct spa_buffer; +struct spa_pod; + +/* Events structs (only used as pointers, full definition in their respective headers) */ +struct pw_core_events; +struct pw_stream_events; + +/* Forward-declare D-Bus types (only used as pointers in method signatures) */ +struct DBusConnection; + +MAA_CTRL_UNIT_NS_BEGIN + +/** + * @brief PipeWire screen capture via xdg-desktop-portal (KDE/KWin). + * + * Captures the entire monitor framebuffer using the ScreenCast portal. + * The workflow is: + * 1. D-Bus handshake with xdg-desktop-portal to create a session, + * select sources, start streaming, and obtain a PipeWire FD. + * 2. Connect to KWin's private PipeWire instance via the portal FD. + * 3. Negotiate format and receive frames via PipeWire callbacks. + * 4. Convert BGRA → BGR and resize to target screen dimensions. + * + * Thread safety: open()/close()/screencap() are NOT safe for concurrent + * calls, but screencap() may be called from a different thread than + * open()/close() as long as they are serialised by the caller. + */ +class PipeWireScreencap +{ +public: + PipeWireScreencap(); + ~PipeWireScreencap(); + + PipeWireScreencap(const PipeWireScreencap&) = delete; + PipeWireScreencap& operator=(const PipeWireScreencap&) = delete; + + void set_screen_size(int width, int height); + + bool open(); + void close(); + bool connected() const; + + bool screencap(cv::Mat& image); + +private: + /* ---- Internal cleanup ---- */ + void close_internal(); + + /* ---- D-Bus (xdg-desktop-portal) ---- */ + bool dbus_create_session(); + bool dbus_select_sources(); + bool dbus_start_stream(); + bool dbus_open_pipewire_remote(DBusConnection* conn); + + /* ---- PipeWire ---- */ + bool pw_init(); + bool pw_create_stream(); + bool pw_connect_stream(uint32_t node_id); + + /* ---- PipeWire callbacks (static, dispatched via pw_stream_events) ---- */ + static void pw_on_core_error(void* data, uint32_t id, int seq, int res, const char* message); + static void pw_on_stream_state_changed(void* data, int old_state, int new_state, const char* error); + static void pw_on_stream_param_changed(void* data, uint32_t id, const struct spa_pod* param); + static void pw_on_stream_process(void* data); + + /* ---- Frame processing ---- */ + bool process_frame(const struct spa_buffer* spa_buf, cv::Mat& out_image); + int infer_dimension_from_stride(uint32_t stride, size_t data_size) const; + + /* ---- Internal state ---- */ + std::atomic connected_{ false }; + bool open_attempted_ = false; + + int screen_width_ = 0; + int screen_height_ = 0; + + /* D-Bus state */ + std::string dbus_session_handle_; + uint32_t pipewire_node_id_ = 0; + int pipewire_fd_ = -1; + + /* PipeWire objects */ + struct pw_thread_loop* pw_thread_loop_ = nullptr; + struct pw_loop* pw_loop_ = nullptr; + ::pw_context* pw_context_ = nullptr; + ::pw_core* pw_core_ = nullptr; + ::pw_stream* pw_stream_ = nullptr; + + /* PipeWire stream hook (embedded, must outlive pw_stream_add_listener) */ + struct spa_hook stream_hook_; + + /* Heap-allocated event vtables (must outlive the listener) */ + struct pw_core_events* core_events_ = nullptr; + struct pw_stream_events* stream_events_ = nullptr; + + /* Negotiated frame dimensions (set by param_changed callback) */ + int frame_width_ = 0; + int frame_height_ = 0; + + /* Synchronisation: stream parameters received */ + std::mutex stream_mutex_; + std::condition_variable stream_cv_; + bool stream_params_received_ = false; + + /* Latest frame data (protected by frame_mutex_) */ + std::mutex frame_mutex_; + cv::Mat latest_frame_; + bool frame_available_ = false; + std::condition_variable frame_cv_; +}; + +MAA_CTRL_UNIT_NS_END diff --git a/source/MaaKWinControlUnit/test_kwin_screencap.cpp b/source/MaaKWinControlUnit/test_kwin_screencap.cpp new file mode 100644 index 0000000000..5284c44f20 --- /dev/null +++ b/source/MaaKWinControlUnit/test_kwin_screencap.cpp @@ -0,0 +1,60 @@ +/** + * @file test_kwin_screencap.c + * @brief Integration test for KWin Control Unit — controller connect + screencap. + * + * Build (from project root): + * cmake --build --preset "NinjaMulti - Release" --target test_kwin_screencap + * + * Usage: + * LD_LIBRARY_PATH=build/lib/Release ./build/bin/test_kwin_screencap [/dev/uinput] [width] [height] + * + * Defaults: /dev/uinput 1920 1080 + */ + +#include "MaaControlUnit/KWinControlUnitAPI.h" +#include +#include +#include + +int main(int argc, char** argv) +{ + const char* device_node = (argc > 1) ? argv[1] : "/dev/uinput"; + int width = (argc > 2) ? atoi(argv[2]) : 1920; + int height = (argc > 3) ? atoi(argv[3]) : 1080; + + printf("=== KWinControlUnit Integration Test ===\n"); + printf("Device node : %s\n", device_node); + printf("Resolution : %dx%d\n", width, height); + printf("Version : %s\n\n", MaaKWinControlUnitGetVersion()); + + /* 1. Create */ + MaaKWinControlUnitHandle handle = MaaKWinControlUnitCreate(device_node, width, height); + if (!handle) { + fprintf(stderr, "FAIL: MaaKWinControlUnitCreate returned NULL\n"); + return 1; + } + printf("[PASS] MaaKWinControlUnitCreate\n"); + + /* 2. Connect (opens /dev/uinput + establishes PipeWire screencast session) */ + MaaBool connected = MaaKWinControlUnitConnect(handle); + if (!connected) { + fprintf(stderr, "FAIL: MaaKWinControlUnitConnect returned false\n"); + MaaKWinControlUnitDestroy(handle); + return 1; + } + printf("[PASS] MaaKWinControlUnitConnect\n"); + + /* 3. Test screencap (capture one frame via PipeWire) */ + MaaBool screencap_ok = MaaKWinControlUnitTestScreencap(handle); + if (!screencap_ok) { + fprintf(stderr, "FAIL: MaaKWinControlUnitTestScreencap returned false\n"); + MaaKWinControlUnitDestroy(handle); + return 1; + } + printf("[PASS] MaaKWinControlUnitTestScreencap\n"); + + /* 4. Cleanup */ + MaaKWinControlUnitDestroy(handle); + printf("\n=== All tests PASSED ===\n"); + return 0; +} diff --git a/source/MaaToolkit/DesktopWindow/DesktopWindowLinuxFinder.cpp b/source/MaaToolkit/DesktopWindow/DesktopWindowLinuxFinder.cpp index cab4b1dca8..25cfbbf77a 100644 --- a/source/MaaToolkit/DesktopWindow/DesktopWindowLinuxFinder.cpp +++ b/source/MaaToolkit/DesktopWindow/DesktopWindowLinuxFinder.cpp @@ -41,7 +41,7 @@ std::vector DesktopWindowLinuxFinder::find_all() const } uint32_t id = 0; - if (int parsed = std::sscanf(filename.c_str(), "wayland-%d", &id); parsed != 1) { + if (int parsed = std::sscanf(filename.c_str(), "wayland-%u", &id); parsed != 1) { LogWarn << "Failed to parse wayland socket name: " << filename; wl_display_disconnect(display); continue; diff --git a/source/include/LibraryHolder/ControlUnit.h b/source/include/LibraryHolder/ControlUnit.h index 69cc7e9180..9d562ca772 100644 --- a/source/include/LibraryHolder/ControlUnit.h +++ b/source/include/LibraryHolder/ControlUnit.h @@ -16,6 +16,7 @@ class MacOSControlUnitAPI; class GamepadControlUnitAPI; class CustomControlUnitAPI; class WlRootsControlUnitAPI; +class KWinControlUnitAPI; class FullControlUnitAPI; class AndroidNativeControlUnitAPI; MAA_CTRL_UNIT_NS_END @@ -169,4 +170,17 @@ class WlRootsControlUnitLibraryHolder : public LibraryHolder +{ +public: + static std::shared_ptr + create_control_unit(const char* device_node, int screen_width, int screen_height); + +private: + inline static const std::filesystem::path libname_ = MAA_NS::path("MaaKWinControlUnit"); + inline static const std::string version_func_name_ = "MaaKWinControlUnitGetVersion"; + inline static const std::string create_func_name_ = "MaaKWinControlUnitCreate"; + inline static const std::string destroy_func_name_ = "MaaKWinControlUnitDestroy"; +}; + MAA_NS_END From 2919acac5ad826b00e2a06722aedd67dc45f079c Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 15:43:09 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=E5=B0=9D=E8=AF=95=E4=BF=AE=E5=A4=8Dc?= =?UTF-8?q?make=E9=97=AE=E9=A2=98=E4=BB=A5=E5=8F=8A=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E6=9D=82=E9=A1=B9=EF=BC=8C=E5=AE=8C=E5=96=84=E9=94=AE=E7=9B=98?= =?UTF-8?q?=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 6 + include/MaaControlUnit/ControlUnitAPI.h | 10 + include/MaaControlUnit/KWinControlUnitAPI.h | 23 ++ include/MaaControlUnit/MaaControlUnitAPI.h | 1 + include/MaaFramework/Instance/MaaController.h | 16 + .../API/KWinControlUnitAPI.cpp | 30 ++ source/MaaKWinControlUnit/CMakeLists.txt | 128 +++++-- .../Input/UInputController.cpp | 152 +++++++- .../Input/UInputController.h | 10 +- .../Manager/KWinControlUnitMgr.cpp | 22 +- .../Screencap/PipeWireScreencap.cpp | 52 +-- .../Screencap/PipeWireScreencap.h | 12 +- .../MaaKWinControlUnit/test_kwin_keyboard.cpp | 344 ++++++++++++++++++ 13 files changed, 728 insertions(+), 78 deletions(-) create mode 100644 include/MaaControlUnit/KWinControlUnitAPI.h create mode 100644 source/MaaKWinControlUnit/test_kwin_keyboard.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f5d5c5be6b..1e1289f1a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ option(WITH_CUSTOM_CONTROLLER "build with custom controller" ON) option(WITH_PLAYCOVER_CONTROLLER "build with PlayCover controller for macOS" ON) option(WITH_GAMEPAD_CONTROLLER "build with virtual gamepad controller for Windows" ON) option(WITH_WLROOTS_CONTROLLER "build with wlroots controller for Linux" ON) +option(WITH_KWIN_CONTROLLER "build with KWin controller for Linux (uinput + PipeWire)" ON) option(WITH_RECORD_CONTROLLER "build with record controller for recording" ON) option(WITH_REPLAY_CONTROLLER "build with replay controller" ON) option(WITH_DBG_CONTROLLER "build with debug controller" OFF) @@ -80,6 +81,11 @@ if(WITH_WLROOTS_CONTROLLER AND (NOT LINUX OR ANDROID)) set(WITH_WLROOTS_CONTROLLER OFF) endif() +if(WITH_KWIN_CONTROLLER AND (NOT LINUX OR ANDROID)) + message(STATUS "Not on Linux, disable WITH_KWIN_CONTROLLER") + set(WITH_KWIN_CONTROLLER OFF) +endif() + if(WITH_MAA_AGENT) find_package(cppzmq REQUIRED) endif() diff --git a/include/MaaControlUnit/ControlUnitAPI.h b/include/MaaControlUnit/ControlUnitAPI.h index bb14219834..ebdaa35606 100644 --- a/include/MaaControlUnit/ControlUnitAPI.h +++ b/include/MaaControlUnit/ControlUnitAPI.h @@ -123,6 +123,15 @@ class WlRootsControlUnitAPI virtual ~WlRootsControlUnitAPI() = default; }; +class KWinControlUnitAPI + : public ControlUnitAPI + , public ScrollableUnit + , public RelativeMovableUnit +{ +public: + virtual ~KWinControlUnitAPI() = default; +}; + class CustomControlUnitAPI : public ControlUnitAPI , public ScrollableUnit @@ -156,6 +165,7 @@ using MaaAdbControlUnitHandle = MAA_CTRL_UNIT_NS::AdbControlUnitAPI*; using MaaWin32ControlUnitHandle = MAA_CTRL_UNIT_NS::Win32ControlUnitAPI*; using MaaMacOSControlUnitHandle = MAA_CTRL_UNIT_NS::MacOSControlUnitAPI*; using MaaWlRootsControlUnitHandle = MAA_CTRL_UNIT_NS::WlRootsControlUnitAPI*; +using MaaKWinControlUnitHandle = MAA_CTRL_UNIT_NS::KWinControlUnitAPI*; using MaaGamepadControlUnitHandle = MAA_CTRL_UNIT_NS::GamepadControlUnitAPI*; using MaaCustomControlUnitHandle = MAA_CTRL_UNIT_NS::CustomControlUnitAPI*; using MaaReplayControlUnitHandle = MAA_CTRL_UNIT_NS::FullControlUnitAPI*; diff --git a/include/MaaControlUnit/KWinControlUnitAPI.h b/include/MaaControlUnit/KWinControlUnitAPI.h new file mode 100644 index 0000000000..8e760cec8b --- /dev/null +++ b/include/MaaControlUnit/KWinControlUnitAPI.h @@ -0,0 +1,23 @@ +#pragma once + +#include "MaaControlUnit/ControlUnitAPI.h" +#include "MaaFramework/MaaDef.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + MAA_CONTROL_UNIT_API const char* MaaKWinControlUnitGetVersion(); + + MAA_CONTROL_UNIT_API MaaKWinControlUnitHandle MaaKWinControlUnitCreate(const char* device_node, int screen_width, int screen_height); + + MAA_CONTROL_UNIT_API MaaBool MaaKWinControlUnitConnect(MaaKWinControlUnitHandle handle); + + MAA_CONTROL_UNIT_API MaaBool MaaKWinControlUnitTestScreencap(MaaKWinControlUnitHandle handle); + + MAA_CONTROL_UNIT_API void MaaKWinControlUnitDestroy(MaaKWinControlUnitHandle handle); + +#ifdef __cplusplus +} +#endif diff --git a/include/MaaControlUnit/MaaControlUnitAPI.h b/include/MaaControlUnit/MaaControlUnitAPI.h index 3478c51b71..1709461584 100644 --- a/include/MaaControlUnit/MaaControlUnitAPI.h +++ b/include/MaaControlUnit/MaaControlUnitAPI.h @@ -16,3 +16,4 @@ #include "ReplayControlUnitAPI.h" #include "Win32ControlUnitAPI.h" #include "WlRootsControlUnitAPI.h" +#include "KWinControlUnitAPI.h" diff --git a/include/MaaFramework/Instance/MaaController.h b/include/MaaFramework/Instance/MaaController.h index 1716135650..068185cba1 100644 --- a/include/MaaFramework/Instance/MaaController.h +++ b/include/MaaFramework/Instance/MaaController.h @@ -128,6 +128,22 @@ extern "C" */ MAA_FRAMEWORK_API MaaController* MaaWlRootsControllerCreate(const char* wlr_socket_path, MaaBool use_win32_vk_code); + /** + * @brief Create a KWin (pure Wayland) controller for Linux. + * + * @param device_node The uinput device node path (e.g., "/dev/uinput"). + * @param screen_width The screen width in pixels. + * @param screen_height The screen height in pixels. + * @return The controller handle, or nullptr on failure. + * + * @note This controller is designed for KWin (pure Wayland) on Linux. + * @note Input is simulated via /dev/uinput (kernel-level virtual touchscreen). + * @note Screencap is not yet implemented (requires PipeWire / xdg-desktop-portal). + * @note Requires write permission to /dev/uinput (typically via the "input" group). + * @note Only single touch is supported (contact must be 0). + */ + MAA_FRAMEWORK_API MaaController* MaaKWinControllerCreate(const char* device_node, int screen_width, int screen_height); + /** * @brief Create a virtual gamepad controller for Windows. * diff --git a/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp b/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp index 68eaf919b3..866319e67a 100644 --- a/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp +++ b/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp @@ -2,6 +2,8 @@ #include "MaaControlUnit/KWinControlUnitAPI.h" +#if defined(__linux__) && !defined(__ANDROID__) + #include "Manager/KWinControlUnitMgr.h" #include "MaaUtils/Logger.h" @@ -77,3 +79,31 @@ void MaaKWinControlUnitDestroy(MaaKWinControlUnitHandle handle) delete handle; } + +#else // !(__linux__ && !__ANDROID__) — stub implementation for unsupported platforms + +const char* MaaKWinControlUnitGetVersion() +{ + return MAA_VERSION; +} + +MaaKWinControlUnitHandle MaaKWinControlUnitCreate(const char* /*device_node*/, int /*screen_width*/, int /*screen_height*/) +{ + return nullptr; +} + +MaaBool MaaKWinControlUnitConnect(MaaKWinControlUnitHandle /*handle*/) +{ + return false; +} + +MaaBool MaaKWinControlUnitTestScreencap(MaaKWinControlUnitHandle /*handle*/) +{ + return false; +} + +void MaaKWinControlUnitDestroy(MaaKWinControlUnitHandle /*handle*/) +{ +} + +#endif // __linux__ && !__ANDROID__ diff --git a/source/MaaKWinControlUnit/CMakeLists.txt b/source/MaaKWinControlUnit/CMakeLists.txt index b77efc7e42..d30cf70aae 100644 --- a/source/MaaKWinControlUnit/CMakeLists.txt +++ b/source/MaaKWinControlUnit/CMakeLists.txt @@ -1,48 +1,112 @@ -# Find PipeWire and D-Bus packages -find_package(PkgConfig REQUIRED) -pkg_check_modules(PIPEWIRE REQUIRED IMPORTED_TARGET libpipewire-0.3) -pkg_check_modules(DBUS1 REQUIRED IMPORTED_TARGET dbus-1) +# --------------------------------------------------------------------------- +# MaaKWinControlUnit — KWin/KDE PipeWire screencap + UInput control +# +# On Linux (non-Android) with PipeWire and D-Bus available, builds the full +# implementation. Otherwise builds a stub that returns errors at runtime. +# --------------------------------------------------------------------------- + +# Source files that are always compiled (stub or full) +set(maa_kwin_control_unit_src + ${CMAKE_CURRENT_SOURCE_DIR}/API/KWinControlUnitAPI.cpp +) + +# Determine whether full implementation is possible +set(MAA_KWIN_HAS_FULL_IMPL FALSE) + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ANDROID) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(PIPEWIRE QUIET libpipewire-0.3) + pkg_check_modules(DBUS1 QUIET dbus-1) + endif() + if(PIPEWIRE_FOUND AND DBUS1_FOUND) + set(MAA_KWIN_HAS_FULL_IMPL TRUE) + endif() +endif() -file(GLOB_RECURSE maa_kwin_control_unit_src *.h *.hpp *.cpp) -file(GLOB_RECURSE maa_kwin_control_unit_header ${MAA_PUBLIC_INC}/MaaControlUnit/KWinControlUnitAPI.h ${MAA_PUBLIC_INC}/MaaControlUnit/ControlUnitAPI.h) +if(MAA_KWIN_HAS_FULL_IMPL) + message(STATUS "MaaKWinControlUnit: full implementation (PipeWire + D-Bus found)") -add_library(MaaKWinControlUnit SHARED ${maa_kwin_control_unit_src} ${maa_kwin_control_unit_header}) + list(APPEND maa_kwin_control_unit_src + ${CMAKE_CURRENT_SOURCE_DIR}/Input/UInputController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/Manager/KWinControlUnitMgr.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/Screencap/PipeWireScreencap.cpp + ) -target_include_directories(MaaKWinControlUnit + file(GLOB_RECURSE maa_kwin_control_unit_header + ${CMAKE_CURRENT_SOURCE_DIR}/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/*.hpp + ) + + add_library(MaaKWinControlUnit SHARED ${maa_kwin_control_unit_src} ${maa_kwin_control_unit_header}) + + target_include_directories(MaaKWinControlUnit PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${MAA_PRIVATE_INC} ${MAA_PUBLIC_INC} ${PIPEWIRE_INCLUDE_DIRS} ${DBUS1_INCLUDE_DIRS}) -target_link_libraries(MaaKWinControlUnit PRIVATE MaaUtils HeaderOnlyLibraries ${OpenCV_LIBS} Boost::system - PkgConfig::PIPEWIRE PkgConfig::DBUS1) + target_link_libraries(MaaKWinControlUnit PRIVATE MaaUtils HeaderOnlyLibraries ${OpenCV_LIBS} Boost::system + ${PIPEWIRE_LIBRARIES} ${DBUS1_LIBRARIES}) -target_compile_definitions(MaaKWinControlUnit PRIVATE MAA_CONTROL_UNIT_EXPORTS) + target_compile_definitions(MaaKWinControlUnit PRIVATE MAA_CONTROL_UNIT_EXPORTS) -# PipeWire/SPA headers use C99 compound literals, GNU case ranges, and other GNU extensions; -# disable the relevant warnings for PipeWireScreencap.cpp only. -set_source_files_properties( - ${CMAKE_CURRENT_SOURCE_DIR}/Screencap/PipeWireScreencap.cpp - PROPERTIES COMPILE_OPTIONS "-Wno-c99-extensions;-Wno-gnu-statement-expression-from-macro-expansion;-Wno-gnu-zero-variadic-macro-arguments;-Wno-gnu-case-range" -) + # PipeWire/SPA headers use C99 compound literals, GNU case ranges, and other GNU extensions; + # disable the relevant warnings for all files that include SPA headers. + set_source_files_properties( + ${CMAKE_CURRENT_SOURCE_DIR}/Screencap/PipeWireScreencap.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/Manager/KWinControlUnitMgr.cpp + PROPERTIES COMPILE_OPTIONS "-Wno-c99-extensions;-Wno-gnu-statement-expression-from-macro-expansion;-Wno-gnu-zero-variadic-macro-arguments;-Wno-gnu-case-range;-Wno-sign-compare" + ) -add_dependencies(MaaKWinControlUnit MaaUtils) + add_dependencies(MaaKWinControlUnit MaaUtils) -install( + install( TARGETS MaaKWinControlUnit RUNTIME DESTINATION bin LIBRARY DESTINATION bin -) + ) -# --------------------------------------------------------------------------- -# Integration test (controller + screencap) -# --------------------------------------------------------------------------- -add_executable(test_kwin_screencap test_kwin_screencap.cpp) -target_link_libraries(test_kwin_screencap PRIVATE MaaKWinControlUnit MaaUtils) -target_include_directories(test_kwin_screencap PRIVATE ${MAA_PUBLIC_INC} - ${OpenCV_INCLUDE_DIRS}) -target_compile_options(test_kwin_screencap PRIVATE -Wno-c11-extensions) -add_dependencies(test_kwin_screencap MaaKWinControlUnit) + # ----------------------------------------------------------------------- + # Integration test (controller + screencap) + # ----------------------------------------------------------------------- + add_executable(test_kwin_screencap ${CMAKE_CURRENT_SOURCE_DIR}/test_kwin_screencap.cpp) + target_link_libraries(test_kwin_screencap PRIVATE MaaKWinControlUnit MaaUtils ${OpenCV_LIBS}) + target_include_directories(test_kwin_screencap PRIVATE ${MAA_PUBLIC_INC} + ${OpenCV_INCLUDE_DIRS}) + target_compile_options(test_kwin_screencap PRIVATE -Wno-c11-extensions) + target_link_options(test_kwin_screencap PRIVATE -Wl,--allow-shlib-undefined) + add_dependencies(test_kwin_screencap MaaKWinControlUnit) + + install(TARGETS test_kwin_screencap RUNTIME DESTINATION bin) + + # ----------------------------------------------------------------------- + # Keyboard input test (key_down / key_up via uinput) + # ----------------------------------------------------------------------- + add_executable(test_kwin_keyboard ${CMAKE_CURRENT_SOURCE_DIR}/test_kwin_keyboard.cpp) + target_link_libraries(test_kwin_keyboard PRIVATE MaaKWinControlUnit MaaUtils ${OpenCV_LIBS}) + target_include_directories(test_kwin_keyboard PRIVATE ${MAA_PUBLIC_INC} + ${OpenCV_INCLUDE_DIRS}) + target_compile_options(test_kwin_keyboard PRIVATE -Wno-c11-extensions) + target_link_options(test_kwin_keyboard PRIVATE -Wl,--allow-shlib-undefined) + add_dependencies(test_kwin_keyboard MaaKWinControlUnit) + + install(TARGETS test_kwin_keyboard RUNTIME DESTINATION bin) + + source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${maa_kwin_control_unit_src} ${maa_kwin_control_unit_header}) +else() + message(STATUS "MaaKWinControlUnit: stub build (PipeWire/D-Bus not available on this platform)") + + add_library(MaaKWinControlUnit SHARED ${maa_kwin_control_unit_src}) -# Install test binary alongside the library -install(TARGETS test_kwin_screencap RUNTIME DESTINATION bin) + target_include_directories(MaaKWinControlUnit + PRIVATE ${MAA_PUBLIC_INC}) -source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${maa_kwin_control_unit_src}) + target_link_libraries(MaaKWinControlUnit PRIVATE MaaUtils HeaderOnlyLibraries) + + target_compile_definitions(MaaKWinControlUnit PRIVATE MAA_CONTROL_UNIT_EXPORTS) + + install( + TARGETS MaaKWinControlUnit + RUNTIME DESTINATION bin + LIBRARY DESTINATION bin + ) +endif() diff --git a/source/MaaKWinControlUnit/Input/UInputController.cpp b/source/MaaKWinControlUnit/Input/UInputController.cpp index ef58ffd767..8c94fece52 100644 --- a/source/MaaKWinControlUnit/Input/UInputController.cpp +++ b/source/MaaKWinControlUnit/Input/UInputController.cpp @@ -1,3 +1,5 @@ +#if defined(__linux__) && !defined(__ANDROID__) + #include "UInputController.h" #include @@ -283,11 +285,89 @@ bool UInputController::relative_move(int dx, int dy) return true; } +bool UInputController::key_down(int key_code) +{ + LogDebug << VAR(key_code); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + int linux_key = maa_to_linux_keycode(key_code); + if (!emit_key(linux_key, 1)) { + return false; + } + if (!emit_syn()) { + return false; + } + return true; +} + +bool UInputController::key_up(int key_code) +{ + LogDebug << VAR(key_code); + + std::unique_lock lock(mutex_); + + if (!connected_) { + LogError << "Not connected"; + return false; + } + + int linux_key = maa_to_linux_keycode(key_code); + if (!emit_key(linux_key, 0)) { + return false; + } + if (!emit_syn()) { + return false; + } + return true; +} + std::pair UInputController::screen_size() const { return { screen_width_, screen_height_ }; } +int UInputController::maa_to_linux_keycode(int key_code) +{ + // ASCII letter mapping + if (key_code >= 'A' && key_code <= 'Z') { + return KEY_A + (key_code - 'A'); + } + if (key_code >= 'a' && key_code <= 'z') { + return KEY_A + (key_code - 'a'); + } + // ASCII digit mapping + if (key_code >= '0' && key_code <= '9') { + return KEY_1 + (key_code - '1'); + } + // Common ASCII / control character mappings + switch (key_code) { + case '\r': + case '\n': return KEY_ENTER; + case '\b': return KEY_BACKSPACE; + case '\t': return KEY_TAB; + case 27: return KEY_ESC; + case ' ': return KEY_SPACE; + case '-': return KEY_MINUS; + case '=': return KEY_EQUAL; + case '[': return KEY_LEFTBRACE; + case ']': return KEY_RIGHTBRACE; + case '\\': return KEY_BACKSLASH; + case ';': return KEY_SEMICOLON; + case '\'': return KEY_APOSTROPHE; + case '`': return KEY_GRAVE; + case ',': return KEY_COMMA; + case '.': return KEY_DOT; + case '/': return KEY_SLASH; + default: return key_code; // pass through as raw Linux keycode + } +} + bool UInputController::create_device() { LogInfo << "Creating uinput device at" << VAR(device_node_); @@ -322,9 +402,7 @@ bool UInputController::create_device() return false; } - // Register ONLY BTN_LEFT — this is a single-button absolute pointer (tablet). - // No BTN_TOUCH, no BTN_TOOL_FINGER, no BTN_RIGHT/MIDDLE. - // This ensures udev identifies it as ID_INPUT_TABLET, not ID_INPUT_TOUCHPAD. + // Register BTN_LEFT for pointer click support if (ioctl(fd_, UI_SET_KEYBIT, BTN_LEFT) < 0) { LogError << "Failed to set BTN_LEFT" << VAR(errno) << VAR(std::strerror(errno)); ::close(fd_); @@ -332,6 +410,70 @@ bool UInputController::create_device() return false; } + // Register alphabet keys (KEY_A .. KEY_Z) + for (int k = KEY_A; k <= KEY_Z; ++k) { + ioctl(fd_, UI_SET_KEYBIT, k); + } + + // Register digit keys (KEY_1 .. KEY_0) + for (int k = KEY_1; k <= KEY_0; ++k) { + ioctl(fd_, UI_SET_KEYBIT, k); + } + + // Register common additional keys + const int kCommonKeys[] = { + KEY_ENTER, + KEY_BACKSPACE, + KEY_TAB, + KEY_ESC, + KEY_SPACE, + KEY_MINUS, + KEY_EQUAL, + KEY_LEFTBRACE, + KEY_RIGHTBRACE, + KEY_BACKSLASH, + KEY_SEMICOLON, + KEY_APOSTROPHE, + KEY_GRAVE, + KEY_COMMA, + KEY_DOT, + KEY_SLASH, + KEY_LEFTSHIFT, + KEY_LEFTCTRL, + KEY_LEFTALT, + KEY_LEFTMETA, + KEY_RIGHTSHIFT, + KEY_RIGHTCTRL, + KEY_RIGHTALT, + KEY_RIGHTMETA, + KEY_CAPSLOCK, + KEY_F1, + KEY_F2, + KEY_F3, + KEY_F4, + KEY_F5, + KEY_F6, + KEY_F7, + KEY_F8, + KEY_F9, + KEY_F10, + KEY_F11, + KEY_F12, + KEY_UP, + KEY_DOWN, + KEY_LEFT, + KEY_RIGHT, + KEY_HOME, + KEY_END, + KEY_PAGEUP, + KEY_PAGEDOWN, + KEY_INSERT, + KEY_DELETE, + }; + for (int k : kCommonKeys) { + ioctl(fd_, UI_SET_KEYBIT, k); + } + // Register ONLY ABS_X and ABS_Y — absolute positioning axes. // No ABS_MT_* axes at all, so udev will NOT classify this as a touchscreen/touchpad. if (ioctl(fd_, UI_SET_ABSBIT, ABS_X) < 0) { @@ -364,7 +506,7 @@ bool UInputController::create_device() // Configure the uinput device using the traditional uinput_user_dev struct. struct uinput_user_dev udev; std::memset(&udev, 0, sizeof(udev)); - std::strncpy(udev.name, "MaaFramework KWin Virtual Pointer", sizeof(udev.name) - 1); + std::strncpy(udev.name, "MaaFramework KWin Virtual Input", sizeof(udev.name) - 1); udev.id.bustype = BUS_USB; udev.id.vendor = 0x1234; udev.id.product = 0x5678; @@ -526,3 +668,5 @@ uint64_t UInputController::now_ms() } MAA_CTRL_UNIT_NS_END + +#endif // __linux__ && !__ANDROID__ diff --git a/source/MaaKWinControlUnit/Input/UInputController.h b/source/MaaKWinControlUnit/Input/UInputController.h index c6ad14ddb1..50749987db 100644 --- a/source/MaaKWinControlUnit/Input/UInputController.h +++ b/source/MaaKWinControlUnit/Input/UInputController.h @@ -1,5 +1,7 @@ #pragma once +#if defined(__linux__) && !defined(__ANDROID__) + #include #include #include @@ -35,6 +37,9 @@ class UInputController bool scroll(int dx, int dy); bool relative_move(int dx, int dy); + bool key_down(int key_code); + bool key_up(int key_code); + std::pair screen_size() const; private: @@ -51,10 +56,11 @@ class UInputController // Send BTN_LEFT=0 + SYN bool send_pointer_up(); + static int maa_to_linux_keycode(int key_code); static uint64_t now_ms(); int fd_ = -1; - bool connected_ = false; + std::atomic connected_{ false }; int screen_width_ = 0; int screen_height_ = 0; @@ -68,3 +74,5 @@ class UInputController }; MAA_CTRL_UNIT_NS_END + +#endif // __linux__ && !__ANDROID__ diff --git a/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp b/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp index d9bc7b7f5f..66d95967fb 100644 --- a/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp +++ b/source/MaaKWinControlUnit/Manager/KWinControlUnitMgr.cpp @@ -1,3 +1,5 @@ +#if defined(__linux__) && !defined(__ANDROID__) + #include "KWinControlUnitMgr.h" #include @@ -181,20 +183,22 @@ bool KWinControlUnitMgr::input_text(const std::string& text) bool KWinControlUnitMgr::key_down(int key) { - // TODO: Implement keyboard key down via uinput - std::ignore = key; + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } - LogError << "key_down not yet implemented for KWin control unit"; - return false; + return input_->key_down(key); } bool KWinControlUnitMgr::key_up(int key) { - // TODO: Implement keyboard key up via uinput - std::ignore = key; + if (!input_) { + LogError << "input_ is nullptr"; + return false; + } - LogError << "key_up not yet implemented for KWin control unit"; - return false; + return input_->key_up(key); } bool KWinControlUnitMgr::relative_move(int dx, int dy) @@ -233,3 +237,5 @@ json::object KWinControlUnitMgr::get_info() const } MAA_CTRL_UNIT_NS_END + +#endif // __linux__ && !__ANDROID__ diff --git a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp index d89ff3957d..dcb86590a3 100644 --- a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp +++ b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp @@ -1,3 +1,5 @@ +#if defined(__linux__) && !defined(__ANDROID__) + #include "PipeWireScreencap.h" #include @@ -12,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -20,7 +21,6 @@ #include #include -#include #include @@ -290,19 +290,17 @@ void PipeWireScreencap::close_internal() delete stream_events_; stream_events_ = nullptr; - delete core_events_; - core_events_ = nullptr; // Reset all state connected_ = false; open_attempted_ = false; - stream_params_received_ = false; frame_available_ = false; frame_width_ = 0; frame_height_ = 0; pipewire_node_id_ = 0; pipewire_fd_ = -1; dbus_session_handle_.clear(); + dbus_connection_ = nullptr; { std::lock_guard lock(frame_mutex_); @@ -477,14 +475,17 @@ bool PipeWireScreencap::dbus_open_pipewire_remote(DBusConnection* conn) // --------------------------------------------------------------------------- bool PipeWireScreencap::dbus_create_session() { - DBusError err; - dbus_error_init(&err); - DBusConnection* conn = dbus_bus_get(DBUS_BUS_SESSION, &err); - if (!conn) { - LogError << "Failed to connect to session bus:" << err.message; - dbus_error_free(&err); - return false; + if (!dbus_connection_) { + DBusError err; + dbus_error_init(&err); + dbus_connection_ = dbus_bus_get(DBUS_BUS_SESSION, &err); + if (!dbus_connection_) { + LogError << "Failed to connect to session bus:" << err.message; + dbus_error_free(&err); + return false; + } } + DBusConnection* conn = dbus_connection_; DBusMessage* msg = dbus_message_new_method_call( kPortalBusName, kPortalPath, kPortalInterface, "CreateSession"); @@ -554,14 +555,11 @@ bool PipeWireScreencap::dbus_create_session() // --------------------------------------------------------------------------- bool PipeWireScreencap::dbus_select_sources() { - DBusError err; - dbus_error_init(&err); - DBusConnection* conn = dbus_bus_get(DBUS_BUS_SESSION, &err); - if (!conn) { - LogError << "Failed to connect to session bus:" << err.message; - dbus_error_free(&err); + if (!dbus_connection_) { + LogError << "D-Bus connection not established (CreateSession must be called first)"; return false; } + DBusConnection* conn = dbus_connection_; DBusMessage* msg = dbus_message_new_method_call( kPortalBusName, kPortalPath, kPortalInterface, "SelectSources"); @@ -627,14 +625,11 @@ bool PipeWireScreencap::dbus_select_sources() // --------------------------------------------------------------------------- bool PipeWireScreencap::dbus_start_stream() { - DBusError err; - dbus_error_init(&err); - DBusConnection* conn = dbus_bus_get(DBUS_BUS_SESSION, &err); - if (!conn) { - LogError << "Failed to connect to session bus:" << err.message; - dbus_error_free(&err); + if (!dbus_connection_) { + LogError << "D-Bus connection not established (CreateSession must be called first)"; return false; } + DBusConnection* conn = dbus_connection_; DBusMessage* msg = dbus_message_new_method_call( kPortalBusName, kPortalPath, kPortalInterface, "Start"); @@ -678,6 +673,7 @@ bool PipeWireScreencap::dbus_start_stream() } // Add match rule so the D-Bus daemon delivers the Response signal + DBusError err; dbus_error_init(&err); std::string match_rule = "type='signal',interface='" + std::string(kPortalRequestInterface) + "',path='" + start_request_path + "'"; @@ -1050,8 +1046,10 @@ bool PipeWireScreencap::process_frame(const struct spa_buffer* spa_buf, cv::Mat& } } else if (data.type == SPA_DATA_DmaBuf) { - LogError << "DMABUF not supported (need EGL import)"; - return false; + // DMABuf cannot be directly mapped via mmap; silently drop the frame + // and return true to indicate "no error, but skip this frame". + LogWarn << "DMABUF buffer type received, dropping frame (EGL import not supported)"; + return true; } else { LogError << "Unsupported buffer type: " << data.type; @@ -1136,3 +1134,5 @@ int PipeWireScreencap::infer_dimension_from_stride(uint32_t stride, size_t /*dat } MAA_CTRL_UNIT_NS_END + +#endif // __linux__ && !__ANDROID__ diff --git a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h index c9af1e774b..5def98dcb0 100644 --- a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h +++ b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.h @@ -1,5 +1,7 @@ #pragma once +#if defined(__linux__) && !defined(__ANDROID__) + #include #include #include @@ -22,7 +24,6 @@ struct spa_buffer; struct spa_pod; /* Events structs (only used as pointers, full definition in their respective headers) */ -struct pw_core_events; struct pw_stream_events; /* Forward-declare D-Bus types (only used as pointers in method signatures) */ @@ -95,6 +96,7 @@ class PipeWireScreencap int screen_height_ = 0; /* D-Bus state */ + DBusConnection* dbus_connection_ = nullptr; std::string dbus_session_handle_; uint32_t pipewire_node_id_ = 0; int pipewire_fd_ = -1; @@ -110,18 +112,12 @@ class PipeWireScreencap struct spa_hook stream_hook_; /* Heap-allocated event vtables (must outlive the listener) */ - struct pw_core_events* core_events_ = nullptr; struct pw_stream_events* stream_events_ = nullptr; /* Negotiated frame dimensions (set by param_changed callback) */ int frame_width_ = 0; int frame_height_ = 0; - /* Synchronisation: stream parameters received */ - std::mutex stream_mutex_; - std::condition_variable stream_cv_; - bool stream_params_received_ = false; - /* Latest frame data (protected by frame_mutex_) */ std::mutex frame_mutex_; cv::Mat latest_frame_; @@ -130,3 +126,5 @@ class PipeWireScreencap }; MAA_CTRL_UNIT_NS_END + +#endif // __linux__ && !__ANDROID__ diff --git a/source/MaaKWinControlUnit/test_kwin_keyboard.cpp b/source/MaaKWinControlUnit/test_kwin_keyboard.cpp new file mode 100644 index 0000000000..afb5e59743 --- /dev/null +++ b/source/MaaKWinControlUnit/test_kwin_keyboard.cpp @@ -0,0 +1,344 @@ +/** + * @file test_kwin_keyboard.cpp + * @brief Integration test for KWin Control Unit — keyboard input (key_down/key_up). + * + * This test verifies: + * 1. Keycode mapping logic for all ASCII letters, digits, symbols, and + * control characters (Phase 1 — pure logic, no device needed). + * 2. Actual uinput keyboard events via key_down/key_up through the + * KWinControlUnitAPI virtual interface (Phase 2 — needs /dev/uinput). + * + * Build (from project root): + * cmake --build --preset "NinjaMulti - Release" --target test_kwin_keyboard + * + * Usage: + * LD_LIBRARY_PATH=build/lib/Release ./build/bin/test_kwin_keyboard [/dev/uinput] + * + * Defaults: /dev/uinput + */ + +#include "MaaControlUnit/KWinControlUnitAPI.h" +#include "MaaControlUnit/ControlUnitAPI.h" + +#include +#include +#include + +#include + +// ─── Keycode mapping reference (mirrors UInputController::maa_to_linux_keycode) ─── + +static int reference_keycode(int c) +{ + // ASCII letter mapping + if (c >= 'A' && c <= 'Z') { + return KEY_A + (c - 'A'); + } + if (c >= 'a' && c <= 'z') { + return KEY_A + (c - 'a'); + } + // ASCII digit mapping (KEY_1 .. KEY_9, KEY_0) + if (c >= '1' && c <= '9') { + return KEY_1 + (c - '1'); + } + if (c == '0') { + return KEY_0; + } + // Common ASCII / control character mappings + switch (c) { + case '\r': + case '\n': return KEY_ENTER; + case '\b': return KEY_BACKSPACE; + case '\t': return KEY_TAB; + case 27: return KEY_ESC; + case ' ': return KEY_SPACE; + case '-': return KEY_MINUS; + case '=': return KEY_EQUAL; + case '[': return KEY_LEFTBRACE; + case ']': return KEY_RIGHTBRACE; + case '\\': return KEY_BACKSLASH; + case ';': return KEY_SEMICOLON; + case '\'': return KEY_APOSTROPHE; + case '`': return KEY_GRAVE; + case ',': return KEY_COMMA; + case '.': return KEY_DOT; + case '/': return KEY_SLASH; + default: return c; // pass through as raw Linux keycode + } +} + +// ─── Test harness ─── + +static int s_passed = 0; +static int s_failed = 0; + +#define TEST(cond, fmt, ...) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " \033[31mFAIL\033[0m: " fmt "\n" __VA_OPT__(, ) \ + __VA_ARGS__); \ + ++s_failed; \ + } else { \ + printf(" \033[32mPASS\033[0m: " fmt "\n" __VA_OPT__(, ) \ + __VA_ARGS__); \ + ++s_passed; \ + } \ + } while (0) + +static int check_keycode(int c, int expected) +{ + int actual = reference_keycode(c); + if (actual != expected) { + fprintf(stderr, + " MISMATCH: char=%d (0x%02x '%c'), expected=KEY_%d, got=%d\n", + c, c, (c >= 32 && c < 127) ? (char)c : '?', expected, actual); + return -1; + } + return 0; +} + +// ─── Phase 1 helpers ─── + +static void test_letter_range() +{ + printf("\n -- Letters A-Z --\n"); + for (int c = 'A'; c <= 'Z'; ++c) { + int exp = KEY_A + (c - 'A'); + TEST(check_keycode(c, exp) == 0, "'%c' -> KEY_%d (%d)", (char)c, exp - KEY_A, exp); + } + + printf("\n -- Letters a-z --\n"); + for (int c = 'a'; c <= 'z'; ++c) { + int exp = KEY_A + (c - 'a'); + TEST(check_keycode(c, exp) == 0, "'%c' -> KEY_%d (%d)", (char)c, exp - KEY_A, exp); + } +} + +static void test_digit_range() +{ + printf("\n -- Digits 0-9 --\n"); + for (int c = '1'; c <= '9'; ++c) { + int exp = KEY_1 + (c - '1'); + TEST(check_keycode(c, exp) == 0, "'%c' -> KEY_%d (%d)", (char)c, c - '0', exp); + } + TEST(check_keycode('0', KEY_0) == 0, "'0' -> KEY_0 (%d)", KEY_0); +} + +static void test_symbol_keys() +{ + printf("\n -- Symbol / punctuation --\n"); + TEST(check_keycode(' ', KEY_SPACE) == 0, "SPACE -> KEY_SPACE (%d)", KEY_SPACE); + TEST(check_keycode('-', KEY_MINUS) == 0, "'-' -> KEY_MINUS (%d)", KEY_MINUS); + TEST(check_keycode('=', KEY_EQUAL) == 0, "'=' -> KEY_EQUAL (%d)", KEY_EQUAL); + TEST(check_keycode('[', KEY_LEFTBRACE) == 0, "'[' -> KEY_LEFTBRACE (%d)", KEY_LEFTBRACE); + TEST(check_keycode(']', KEY_RIGHTBRACE) == 0, "']' -> KEY_RIGHTBRACE (%d)", KEY_RIGHTBRACE); + TEST(check_keycode('\\', KEY_BACKSLASH) == 0, "'\\' -> KEY_BACKSLASH (%d)", KEY_BACKSLASH); + TEST(check_keycode(';', KEY_SEMICOLON) == 0, "';' -> KEY_SEMICOLON (%d)", KEY_SEMICOLON); + TEST(check_keycode('\'', KEY_APOSTROPHE) == 0, "''' -> KEY_APOSTROPHE (%d)", KEY_APOSTROPHE); + TEST(check_keycode('`', KEY_GRAVE) == 0, "'`' -> KEY_GRAVE (%d)", KEY_GRAVE); + TEST(check_keycode(',', KEY_COMMA) == 0, "',' -> KEY_COMMA (%d)", KEY_COMMA); + TEST(check_keycode('.', KEY_DOT) == 0, "'.' -> KEY_DOT (%d)", KEY_DOT); + TEST(check_keycode('/', KEY_SLASH) == 0, "'/' -> KEY_SLASH (%d)", KEY_SLASH); +} + +static void test_control_characters() +{ + printf("\n -- Control characters --\n"); + TEST(check_keycode('\r', KEY_ENTER) == 0, "CR -> KEY_ENTER (%d)", KEY_ENTER); + TEST(check_keycode('\n', KEY_ENTER) == 0, "LF -> KEY_ENTER (%d)", KEY_ENTER); + TEST(check_keycode('\b', KEY_BACKSPACE) == 0, "BS -> KEY_BACKSPACE (%d)", KEY_BACKSPACE); + TEST(check_keycode('\t', KEY_TAB) == 0, "TAB -> KEY_TAB (%d)", KEY_TAB); + TEST(check_keycode(27, KEY_ESC) == 0, "ESC -> KEY_ESC (%d)", KEY_ESC); +} + +static void test_raw_passthrough() +{ + printf("\n -- Raw keycode passthrough (non-ASCII range) --\n"); + // NOTE: Linux keycodes whose numeric value collides with ASCII characters + // (e.g. KEY_RIGHTCTRL=97 == 'a', KEY_F1=59 == ';') will be mapped as ASCII + // by maa_to_linux_keycode(). This is a known design limitation: the function + // cannot distinguish "raw keycode 97" from "char 'a'" since both are ints. + // Only keycodes OUTSIDE the ASCII mapping ranges pass through unchanged. + + // KEY_LEFTSHIFT=42 — '*' is not in the mapping switch, so it passes through + TEST(check_keycode(KEY_LEFTSHIFT, KEY_LEFTSHIFT) == 0, + "KEY_LEFTSHIFT (%d) passthrough", KEY_LEFTSHIFT); + // KEY_LEFTMETA=125 — '}' is not in the mapping switch + TEST(check_keycode(KEY_LEFTMETA, KEY_LEFTMETA) == 0, + "KEY_LEFTMETA (%d) passthrough", KEY_LEFTMETA); + // KEY_CAPSLOCK=58 — ':' is not in the mapping switch + TEST(check_keycode(KEY_CAPSLOCK, KEY_CAPSLOCK) == 0, + "KEY_CAPSLOCK (%d) passthrough", KEY_CAPSLOCK); + // KEY_RIGHTSHIFT=54 — '6' would map to KEY_6, so skip + // KEY_RIGHTCTRL=97 — collides with 'a' + // KEY_LEFTALT=56 — collides with '8' + // KEY_F1=59 — collides with ';' + // KEY_UP=103 — collides with 'g' + // KEY_DOWN=108 — collides with 'l' + // KEY_LEFT=105 — collides with 'i' + // KEY_RIGHT=106 — collides with 'j' + // KEY_HOME=102 — collides with 'f' + // KEY_END=107 — collides with 'k' + // KEY_PAGEUP=104 — collides with 'h' + // KEY_INSERT=110 — collides with 'n' + // KEY_DELETE=111 — collides with 'o' +} + +// ─── Phase 2: uinput integration helpers ─── + +static void test_single_key(MAA_CTRL_UNIT_NS::ControlUnitAPI* ctrl, int key, const char* label) +{ + bool ok = ctrl->key_down(key) && ctrl->key_up(key); + TEST(ok, "key_down/key_up %s", label); +} + +static void test_key_sequence(MAA_CTRL_UNIT_NS::ControlUnitAPI* ctrl, + const char* sequence, const char* label) +{ + printf("\n -- %s --\n", label); + printf(" Typing: \"%s\"\n", sequence); + + bool ok = true; + for (const char* p = sequence; *p; ++p) { + if (!ctrl->key_down(static_cast(*p))) { + ok = false; + fprintf(stderr, " FAIL at char '%c' (0x%02x) during key_down\n", *p, *p); + break; + } + if (!ctrl->key_up(static_cast(*p))) { + ok = false; + fprintf(stderr, " FAIL at char '%c' (0x%02x) during key_up\n", *p, *p); + break; + } + } + TEST(ok, "Type sequence \"%s\"", label); +} + +// ─── Main ─── + +int main(int argc, char** argv) +{ + const char* device_node = (argc > 1) ? argv[1] : "/dev/uinput"; + constexpr int kWidth = 1920; + constexpr int kHeight = 1080; + + printf("=== KWinControlUnit Keyboard Input Test ===\n"); + printf(" Device node : %s\n", device_node); + printf(" Resolution : %dx%d\n", kWidth, kHeight); + printf(" Version : %s\n\n", MaaKWinControlUnitGetVersion()); + + // ═══════════════════════════════════════════════════════════════════════ + // Phase 1: Keycode Mapping (pure logic, no device needed) + // ═══════════════════════════════════════════════════════════════════════ + printf("═══════════════════════════════════════════════════\n"); + printf(" Phase 1: Keycode Mapping Verification\n"); + printf("═══════════════════════════════════════════════════\n"); + + test_letter_range(); + test_digit_range(); + test_symbol_keys(); + test_control_characters(); + test_raw_passthrough(); + + printf("\n Phase 1 results: %d passed, %d failed\n\n", s_passed, s_failed); + if (s_failed > 0) { + // Phase 1 failures indicate a logic error in the mapping — abort early + fprintf(stderr, "FATAL: Keycode mapping failures detected. Aborting.\n"); + return 1; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Phase 2: UInput Keyboard Integration (needs /dev/uinput) + // ═══════════════════════════════════════════════════════════════════════ + printf("═══════════════════════════════════════════════════\n"); + printf(" Phase 2: UInput Keyboard Integration\n"); + printf("═══════════════════════════════════════════════════\n"); + + MaaKWinControlUnitHandle handle = MaaKWinControlUnitCreate(device_node, kWidth, kHeight); + if (!handle) { + fprintf(stderr, "FAIL: MaaKWinControlUnitCreate returned NULL\n"); + return 1; + } + printf(" [PASS] MaaKWinControlUnitCreate\n"); + + MaaBool connected = MaaKWinControlUnitConnect(handle); + if (!connected) { + fprintf(stderr, "FAIL: MaaKWinControlUnitConnect returned false\n"); + fprintf(stderr, " (need write access to %s and permissions)\n", device_node); + MaaKWinControlUnitDestroy(handle); + // Phase 2 failures may be environmental — still report Phase 1 results + printf("\n Phase 2 SKIPPED (no /dev/uinput access)\n"); + printf("\n=== Summary: %d passed, %d failed (Phase 2 skipped) ===\n", + s_passed, s_failed); + return s_failed > 0 ? 1 : 0; + } + printf(" [PASS] MaaKWinControlUnitConnect\n"); + + // Cast to ControlUnitAPI to access key_down/key_up virtual methods + MAA_CTRL_UNIT_NS::ControlUnitAPI* ctrl = + static_cast(handle); + if (!ctrl) { + fprintf(stderr, "FAIL: handle cast to ControlUnitAPI failed\n"); + MaaKWinControlUnitDestroy(handle); + return 1; + } + printf(" [PASS] handle cast to ControlUnitAPI\n"); + + // --- Single letter keys --- + printf("\n -- Single letter keys --\n"); + test_single_key(ctrl, 'A', "'A'"); + test_single_key(ctrl, 'M', "'M'"); + test_single_key(ctrl, 'Z', "'Z'"); + test_single_key(ctrl, 'a', "'a'"); + test_single_key(ctrl, 'z', "'z'"); + + // --- Single digit keys --- + printf("\n -- Single digit keys --\n"); + test_single_key(ctrl, '1', "'1'"); + test_single_key(ctrl, '5', "'5'"); + test_single_key(ctrl, '9', "'9'"); + test_single_key(ctrl, '0', "'0'"); + + // --- Single symbol keys --- + printf("\n -- Single symbol keys --\n"); + test_single_key(ctrl, '-', "'-'"); + test_single_key(ctrl, '=', "'='"); + test_single_key(ctrl, '[', "'['"); + test_single_key(ctrl, ']', "']'"); + test_single_key(ctrl, ';', "';'"); + test_single_key(ctrl, ',', "','"); + test_single_key(ctrl, '.', "'.'"); + test_single_key(ctrl, '/', "'/'"); + test_single_key(ctrl, ' ', "SPACE"); + + // --- Single control characters --- + printf("\n -- Single control characters --\n"); + test_single_key(ctrl, '\n', "ENTER"); + test_single_key(ctrl, '\b', "BACKSPACE"); + test_single_key(ctrl, '\t', "TAB"); + test_single_key(ctrl, 27, "ESC"); + + // --- Raw Linux keycodes --- + printf("\n -- Raw Linux keycodes (non-ASCII range) --\n"); + // NOTE: Keycodes < 32 (control chars) map to ASCII equivalents. + // Keycodes 32-127 that overlap with ASCII letters/digits/symbols also map. + // Only keycodes OUTSIDE the ASCII mapping (e.g. >= 128 or unmapped symbols) + // pass through unchanged. See Phase 1 raw_passthrough note. + test_single_key(ctrl, KEY_LEFTSHIFT, "KEY_LEFTSHIFT"); + test_single_key(ctrl, KEY_LEFTMETA, "KEY_LEFTMETA (Super)"); + test_single_key(ctrl, KEY_CAPSLOCK, "KEY_CAPSLOCK"); + + // --- Key sequences (type a word) --- + test_key_sequence(ctrl, "Hello", "Hello"); + test_key_sequence(ctrl, "MaaFramework", "MaaFramework"); + test_key_sequence(ctrl, "KWin Control Unit 2024", "with spaces and digits"); + + // Cleanup + MaaKWinControlUnitDestroy(handle); + + printf("\n═══════════════════════════════════════════════════\n"); + printf(" Final: %d passed, %d failed\n", s_passed, s_failed); + printf("═══════════════════════════════════════════════════\n"); + + return s_failed > 0 ? 1 : 0; +} From 16e8b6b067844187c6252993db48c95da5fbbe00 Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 16:07:17 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:ci=E6=B7=BB=E5=8A=A0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 2 +- .github/workflows/test.yml | 2 +- source/MaaKWinControlUnit/CMakeLists.txt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fb8119bd83..706cc006df 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -174,7 +174,7 @@ jobs: - name: Install dep run: | sudo apt-get update -y - sudo apt-get install -y ninja-build cmake ccache + sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev - uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f41a5e5e7..29289c9dc4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -179,7 +179,7 @@ jobs: - name: Install dep run: | sudo apt-get update -y - sudo apt-get install -y ninja-build cmake ccache + sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev # https://github.com/MaaXYZ/MaaFramework/actions/runs/5643408179/job/15285186255 - uses: actions/checkout@v4 diff --git a/source/MaaKWinControlUnit/CMakeLists.txt b/source/MaaKWinControlUnit/CMakeLists.txt index d30cf70aae..83f9eda22d 100644 --- a/source/MaaKWinControlUnit/CMakeLists.txt +++ b/source/MaaKWinControlUnit/CMakeLists.txt @@ -98,9 +98,9 @@ else() add_library(MaaKWinControlUnit SHARED ${maa_kwin_control_unit_src}) target_include_directories(MaaKWinControlUnit - PRIVATE ${MAA_PUBLIC_INC}) + PRIVATE ${MAA_PUBLIC_INC} ${OpenCV_INCLUDE_DIRS}) - target_link_libraries(MaaKWinControlUnit PRIVATE MaaUtils HeaderOnlyLibraries) + target_link_libraries(MaaKWinControlUnit PRIVATE MaaUtils HeaderOnlyLibraries ${OpenCV_LIBS}) target_compile_definitions(MaaKWinControlUnit PRIVATE MAA_CONTROL_UNIT_EXPORTS) From d042b82a5249496167d2485ebce6402900735404 Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 17:13:09 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:ci=E6=94=B9=E7=94=A8=E7=BB=9D?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84=E9=93=BE=E6=8E=A5pipewire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/MaaKWinControlUnit/CMakeLists.txt | 32 +++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/source/MaaKWinControlUnit/CMakeLists.txt b/source/MaaKWinControlUnit/CMakeLists.txt index 83f9eda22d..3f13f61892 100644 --- a/source/MaaKWinControlUnit/CMakeLists.txt +++ b/source/MaaKWinControlUnit/CMakeLists.txt @@ -11,21 +11,33 @@ set(maa_kwin_control_unit_src ) # Determine whether full implementation is possible -set(MAA_KWIN_HAS_FULL_IMPL FALSE) +# Use absolute library paths (NO_CMAKE_FIND_ROOT_PATH) to bypass the cross-compilation +# sysroot sandbox, since -l flags would be resolved inside the sysroot where PipeWire +# and D-Bus are not installed. +set(PIPEWIRE_ABS_LIB) +set(DBUS_ABS_LIB) if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ANDROID) find_package(PkgConfig QUIET) if(PkgConfig_FOUND) pkg_check_modules(PIPEWIRE QUIET libpipewire-0.3) - pkg_check_modules(DBUS1 QUIET dbus-1) + pkg_check_modules(DBUS QUIET dbus-1) endif() - if(PIPEWIRE_FOUND AND DBUS1_FOUND) - set(MAA_KWIN_HAS_FULL_IMPL TRUE) + if(PIPEWIRE_FOUND AND DBUS_FOUND) + # Search for absolute paths on the HOST filesystem, NOT inside the sysroot + find_library(PIPEWIRE_ABS_LIB + NAMES pipewire-0.3 + PATHS ${PIPEWIRE_LIBRARY_DIRS} /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu + NO_CMAKE_FIND_ROOT_PATH) + find_library(DBUS_ABS_LIB + NAMES dbus-1 + PATHS ${DBUS_LIBRARY_DIRS} /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu + NO_CMAKE_FIND_ROOT_PATH) endif() endif() -if(MAA_KWIN_HAS_FULL_IMPL) - message(STATUS "MaaKWinControlUnit: full implementation (PipeWire + D-Bus found)") +if(PIPEWIRE_ABS_LIB AND DBUS_ABS_LIB) + message(STATUS "MaaKWinControlUnit: full implementation (absolute paths: ${PIPEWIRE_ABS_LIB}, ${DBUS_ABS_LIB})") list(APPEND maa_kwin_control_unit_src ${CMAKE_CURRENT_SOURCE_DIR}/Input/UInputController.cpp @@ -42,10 +54,12 @@ if(MAA_KWIN_HAS_FULL_IMPL) target_include_directories(MaaKWinControlUnit PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${MAA_PRIVATE_INC} ${MAA_PUBLIC_INC} - ${PIPEWIRE_INCLUDE_DIRS} ${DBUS1_INCLUDE_DIRS}) + ${PIPEWIRE_INCLUDE_DIRS} ${DBUS_INCLUDE_DIRS}) + # Link with absolute paths — CMake will embed the full path in the linker command + # instead of generating -l flags, so the linker bypasses the sysroot entirely. target_link_libraries(MaaKWinControlUnit PRIVATE MaaUtils HeaderOnlyLibraries ${OpenCV_LIBS} Boost::system - ${PIPEWIRE_LIBRARIES} ${DBUS1_LIBRARIES}) + ${PIPEWIRE_ABS_LIB} ${DBUS_ABS_LIB}) target_compile_definitions(MaaKWinControlUnit PRIVATE MAA_CONTROL_UNIT_EXPORTS) @@ -93,7 +107,7 @@ if(MAA_KWIN_HAS_FULL_IMPL) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${maa_kwin_control_unit_src} ${maa_kwin_control_unit_header}) else() - message(STATUS "MaaKWinControlUnit: stub build (PipeWire/D-Bus not available on this platform)") + message(STATUS "MaaKWinControlUnit: stub build (absolute PipeWire/D-Bus libraries not found on this platform)") add_library(MaaKWinControlUnit SHARED ${maa_kwin_control_unit_src}) From 0c8de84a2402bcdeebbc4bac397e1efd7dc7a46b Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 17:44:43 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8Daarch64=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 6 ++++++ .github/workflows/test.yml | 6 ++++++ source/MaaKWinControlUnit/CMakeLists.txt | 14 +++++++++++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 706cc006df..821c387aaf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -175,6 +175,12 @@ jobs: run: | sudo apt-get update -y sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev + if [[ '${{ matrix.arch }}' == 'aarch64' ]]; then + sudo dpkg --add-architecture arm64 + sudo apt-get update + sudo apt-get install -y libpipewire-0.3-dev:arm64 libdbus-1-dev:arm64 + echo "PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV" + fi - uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29289c9dc4..f8078b77c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -180,6 +180,12 @@ jobs: run: | sudo apt-get update -y sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev + if [[ '${{ matrix.arch }}' == 'aarch64' ]]; then + sudo dpkg --add-architecture arm64 + sudo apt-get update + sudo apt-get install -y libpipewire-0.3-dev:arm64 libdbus-1-dev:arm64 + echo "PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV" + fi # https://github.com/MaaXYZ/MaaFramework/actions/runs/5643408179/job/15285186255 - uses: actions/checkout@v4 diff --git a/source/MaaKWinControlUnit/CMakeLists.txt b/source/MaaKWinControlUnit/CMakeLists.txt index 3f13f61892..408f02fa20 100644 --- a/source/MaaKWinControlUnit/CMakeLists.txt +++ b/source/MaaKWinControlUnit/CMakeLists.txt @@ -14,6 +14,12 @@ set(maa_kwin_control_unit_src # Use absolute library paths (NO_CMAKE_FIND_ROOT_PATH) to bypass the cross-compilation # sysroot sandbox, since -l flags would be resolved inside the sysroot where PipeWire # and D-Bus are not installed. +# +# Architecture detection is handled via PKG_CONFIG_LIBDIR. In CI, for aarch64 +# cross-compilation, the variable is set to /usr/lib/aarch64-linux-gnu/pkgconfig +# so pkg_check_modules populates PIPEWIRE_LIBRARY_DIRS / DBUS_LIBRARY_DIRS with +# the correct architecture-specific path. For x86_64 native builds, the default +# pkg-config search path is used. set(PIPEWIRE_ABS_LIB) set(DBUS_ABS_LIB) @@ -24,14 +30,16 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ANDROID) pkg_check_modules(DBUS QUIET dbus-1) endif() if(PIPEWIRE_FOUND AND DBUS_FOUND) - # Search for absolute paths on the HOST filesystem, NOT inside the sysroot + # Search for absolute paths on the HOST filesystem, NOT inside the sysroot. + # PKG_CONFIG_LIBDIR (set in CI for cross-compilation) ensures the LIBRARY_DIRS + # points to the correct architecture path (e.g. /usr/lib/aarch64-linux-gnu). find_library(PIPEWIRE_ABS_LIB NAMES pipewire-0.3 - PATHS ${PIPEWIRE_LIBRARY_DIRS} /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu + PATHS ${PIPEWIRE_LIBRARY_DIRS} NO_CMAKE_FIND_ROOT_PATH) find_library(DBUS_ABS_LIB NAMES dbus-1 - PATHS ${DBUS_LIBRARY_DIRS} /usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu + PATHS ${DBUS_LIBRARY_DIRS} NO_CMAKE_FIND_ROOT_PATH) endif() endif() From b5982d7e71c595f0275a5514b9426a19267d25d4 Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 18:27:34 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=E5=86=8D=E6=AC=A1=E5=B0=9D=E8=AF=95?= =?UTF-8?q?=E4=BF=AE=E5=A4=8Daarch64=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 49 +++++++++++++++++++++++- .github/workflows/test.yml | 49 +++++++++++++++++++++++- source/MaaKWinControlUnit/CMakeLists.txt | 25 +++++------- 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 821c387aaf..30918ed391 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -176,10 +176,55 @@ jobs: sudo apt-get update -y sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev if [[ '${{ matrix.arch }}' == 'aarch64' ]]; then + # ------------------------------------------------------------------ + # ARM64 cross-compilation: inject PipeWire + D-Bus into x-tools sysroot + # + # Architecture pinning prevents 404 errors by restricting the default + # apt sources to amd64 only, then adding an explicit ARM64 Ports source. + # apt-get download dynamically resolves the dependency tree so it always + # fetches the latest package versions. + # ------------------------------------------------------------------ + + # 1. Enable arm64 architecture support sudo dpkg --add-architecture arm64 + + # 2. Architecture pinning: force existing sources to serve amd64 only + sudo sed -i 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list 2>/dev/null || true + sudo sed -i 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list 2>/dev/null || true + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i 's/^Types: deb$/Types: deb\nArchitectures: amd64/g' /etc/apt/sources.list.d/ubuntu.sources + fi + + # 3. Inject dedicated ARM64 official Ports source + cat <> "$GITHUB_ENV" + + # 5. Download latest ARM64 .deb packages + mkdir -p arm64_deps && cd arm64_deps + apt-get download \ + libdbus-1-dev:arm64 libdbus-1-3:arm64 \ + libpipewire-0.3-dev:arm64 libpipewire-0.3-0t64:arm64 \ + libspa-0.2-dev:arm64 libspa-0.2-modules:arm64 + + # 6. Extract and inject into x-tools sysroot + mkdir -p ./extracted + for deb in *.deb; do + dpkg -x "$deb" ./extracted + done + + SYSROOT_DIR="${{ github.workspace }}/source/MaaUtils/MaaDeps/x-tools/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + cp -r ./extracted/usr/include/* "$SYSROOT_DIR/usr/include/" + cp -r ./extracted/usr/lib/aarch64-linux-gnu/* "$SYSROOT_DIR/usr/lib/" + + cd .. fi - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8078b77c4..4aa4669bc4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -181,10 +181,55 @@ jobs: sudo apt-get update -y sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev if [[ '${{ matrix.arch }}' == 'aarch64' ]]; then + # ------------------------------------------------------------------ + # ARM64 cross-compilation: inject PipeWire + D-Bus into x-tools sysroot + # + # Architecture pinning prevents 404 errors by restricting the default + # apt sources to amd64 only, then adding an explicit ARM64 Ports source. + # apt-get download dynamically resolves the dependency tree so it always + # fetches the latest package versions. + # ------------------------------------------------------------------ + + # 1. Enable arm64 architecture support sudo dpkg --add-architecture arm64 + + # 2. Architecture pinning: force existing sources to serve amd64 only + sudo sed -i 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list 2>/dev/null || true + sudo sed -i 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list 2>/dev/null || true + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i 's/^Types: deb$/Types: deb\nArchitectures: amd64/g' /etc/apt/sources.list.d/ubuntu.sources + fi + + # 3. Inject dedicated ARM64 official Ports source + cat <> "$GITHUB_ENV" + + # 5. Download latest ARM64 .deb packages + mkdir -p arm64_deps && cd arm64_deps + apt-get download \ + libdbus-1-dev:arm64 libdbus-1-3:arm64 \ + libpipewire-0.3-dev:arm64 libpipewire-0.3-0t64:arm64 \ + libspa-0.2-dev:arm64 libspa-0.2-modules:arm64 + + # 6. Extract and inject into x-tools sysroot + mkdir -p ./extracted + for deb in *.deb; do + dpkg -x "$deb" ./extracted + done + + SYSROOT_DIR="${{ github.workspace }}/source/MaaUtils/MaaDeps/x-tools/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + cp -r ./extracted/usr/include/* "$SYSROOT_DIR/usr/include/" + cp -r ./extracted/usr/lib/aarch64-linux-gnu/* "$SYSROOT_DIR/usr/lib/" + + cd .. fi # https://github.com/MaaXYZ/MaaFramework/actions/runs/5643408179/job/15285186255 diff --git a/source/MaaKWinControlUnit/CMakeLists.txt b/source/MaaKWinControlUnit/CMakeLists.txt index 408f02fa20..fd206ea3b2 100644 --- a/source/MaaKWinControlUnit/CMakeLists.txt +++ b/source/MaaKWinControlUnit/CMakeLists.txt @@ -11,15 +11,10 @@ set(maa_kwin_control_unit_src ) # Determine whether full implementation is possible -# Use absolute library paths (NO_CMAKE_FIND_ROOT_PATH) to bypass the cross-compilation -# sysroot sandbox, since -l flags would be resolved inside the sysroot where PipeWire -# and D-Bus are not installed. -# -# Architecture detection is handled via PKG_CONFIG_LIBDIR. In CI, for aarch64 -# cross-compilation, the variable is set to /usr/lib/aarch64-linux-gnu/pkgconfig -# so pkg_check_modules populates PIPEWIRE_LIBRARY_DIRS / DBUS_LIBRARY_DIRS with -# the correct architecture-specific path. For x86_64 native builds, the default -# pkg-config search path is used. +# Libraries are found inside the sysroot via default CMAKE_FIND_ROOT_PATH behavior. +# In CI, for aarch64 cross-compilation, the ARM64 .deb packages are downloaded and +# extracted directly into the x-tools sysroot under /usr/lib/, so CMake +# finds them naturally. For x86_64 native builds, pkg-config finds the system libs. set(PIPEWIRE_ABS_LIB) set(DBUS_ABS_LIB) @@ -30,17 +25,15 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ANDROID) pkg_check_modules(DBUS QUIET dbus-1) endif() if(PIPEWIRE_FOUND AND DBUS_FOUND) - # Search for absolute paths on the HOST filesystem, NOT inside the sysroot. - # PKG_CONFIG_LIBDIR (set in CI for cross-compilation) ensures the LIBRARY_DIRS - # points to the correct architecture path (e.g. /usr/lib/aarch64-linux-gnu). + # Search inside the sysroot (default CMAKE_FIND_ROOT_PATH behavior). In CI, + # the ARM64 .deb packages are extracted and injected directly into the x-tools + # sysroot, so the libraries are found naturally under /usr/lib/. find_library(PIPEWIRE_ABS_LIB NAMES pipewire-0.3 - PATHS ${PIPEWIRE_LIBRARY_DIRS} - NO_CMAKE_FIND_ROOT_PATH) + PATHS ${PIPEWIRE_LIBRARY_DIRS} /usr/lib) find_library(DBUS_ABS_LIB NAMES dbus-1 - PATHS ${DBUS_LIBRARY_DIRS} - NO_CMAKE_FIND_ROOT_PATH) + PATHS ${DBUS_LIBRARY_DIRS} /usr/lib) endif() endif() From 1ae0b83528261177d90174a3f8ea37d42e45ea19 Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 18:39:46 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=E7=BC=A9=E8=BF=9B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 12 ++++++------ .github/workflows/test.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30918ed391..c8300df14a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -197,12 +197,12 @@ jobs: # 3. Inject dedicated ARM64 official Ports source cat < Date: Sat, 6 Jun 2026 19:10:41 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:Ci=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 15 ++++++++------- .github/workflows/test.yml | 15 ++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8300df14a..d6cacc3133 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -196,13 +196,14 @@ jobs: fi # 3. Inject dedicated ARM64 official Ports source - cat < /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "URIs: http://ports.ubuntu.com/ubuntu-ports" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Suites: noble noble-updates noble-security" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Components: main universe restricted multiverse" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Architectures: arm64" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' # 4. Update (no 404 since sources are pinned per-arch) sudo apt-get update diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eebf7b4636..b39a65e6cb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -201,13 +201,14 @@ jobs: fi # 3. Inject dedicated ARM64 official Ports source - cat < /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "URIs: http://ports.ubuntu.com/ubuntu-ports" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Suites: noble noble-updates noble-security" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Components: main universe restricted multiverse" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Architectures: arm64" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' # 4. Update (no 404 since sources are pinned per-arch) sudo apt-get update From 0ece112b7066ebe9acae00712bf15c4ee8352dd6 Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sat, 6 Jun 2026 19:48:41 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix:Ci=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 89 +++++++++++++++---------------------- .github/workflows/test.yml | 89 +++++++++++++++---------------------- 2 files changed, 74 insertions(+), 104 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6cacc3133..45f31f7a77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -175,58 +175,6 @@ jobs: run: | sudo apt-get update -y sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev - if [[ '${{ matrix.arch }}' == 'aarch64' ]]; then - # ------------------------------------------------------------------ - # ARM64 cross-compilation: inject PipeWire + D-Bus into x-tools sysroot - # - # Architecture pinning prevents 404 errors by restricting the default - # apt sources to amd64 only, then adding an explicit ARM64 Ports source. - # apt-get download dynamically resolves the dependency tree so it always - # fetches the latest package versions. - # ------------------------------------------------------------------ - - # 1. Enable arm64 architecture support - sudo dpkg --add-architecture arm64 - - # 2. Architecture pinning: force existing sources to serve amd64 only - sudo sed -i 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list 2>/dev/null || true - sudo sed -i 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list 2>/dev/null || true - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i 's/^Types: deb$/Types: deb\nArchitectures: amd64/g' /etc/apt/sources.list.d/ubuntu.sources - fi - - # 3. Inject dedicated ARM64 official Ports source - # NOTE: heredoc (cat < /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "URIs: http://ports.ubuntu.com/ubuntu-ports" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "Suites: noble noble-updates noble-security" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "Components: main universe restricted multiverse" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "Architectures: arm64" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - - # 4. Update (no 404 since sources are pinned per-arch) - sudo apt-get update - - # 5. Download latest ARM64 .deb packages - mkdir -p arm64_deps && cd arm64_deps - apt-get download \ - libdbus-1-dev:arm64 libdbus-1-3:arm64 \ - libpipewire-0.3-dev:arm64 libpipewire-0.3-0t64:arm64 \ - libspa-0.2-dev:arm64 libspa-0.2-modules:arm64 - - # 6. Extract and inject into x-tools sysroot - mkdir -p ./extracted - for deb in *.deb; do - dpkg -x "$deb" ./extracted - done - - SYSROOT_DIR="${{ github.workspace }}/source/MaaUtils/MaaDeps/x-tools/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" - cp -r ./extracted/usr/include/* "$SYSROOT_DIR/usr/include/" - cp -r ./extracted/usr/lib/aarch64-linux-gnu/* "$SYSROOT_DIR/usr/lib/" - - cd .. - fi - uses: pnpm/action-setup@v4 with: @@ -258,6 +206,43 @@ jobs: run: | python3 tools/maadeps-download.py ${{ matrix.arch == 'x86_64' && 'x64' || 'arm64' }}-linux + - name: Inject ARM64 Dependencies + if: matrix.arch == 'aarch64' + run: | + echo "Installing ARM64 cross-compile dependencies..." + sudo dpkg --add-architecture arm64 + sudo sed -i 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list 2>/dev/null || true + sudo sed -i 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list 2>/dev/null || true + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i 's/^Types: deb$/Types: deb\nArchitectures: amd64/g' /etc/apt/sources.list.d/ubuntu.sources + fi + + sudo sh -c 'echo "Types: deb" > /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "URIs: http://ports.ubuntu.com/ubuntu-ports" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Suites: noble noble-updates noble-security" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Components: main universe restricted multiverse" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Architectures: arm64" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + + sudo apt-get update -y + + mkdir -p arm64_deps && cd arm64_deps + sudo apt-get download libdbus-1-dev:arm64 libdbus-1-3:arm64 libpipewire-0.3-dev:arm64 libpipewire-0.3-0t64:arm64 libspa-0.2-dev:arm64 libspa-0.2-modules:arm64 + + mkdir -p ../extracted + for deb in *.deb; do + dpkg -x "$deb" ../extracted + done + cd .. + + SYSROOT_DIR="${GITHUB_WORKSPACE}/source/MaaUtils/MaaDeps/x-tools/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + + # 确保目录结构存在后再拷贝 + sudo mkdir -p "$SYSROOT_DIR/usr/include" + sudo mkdir -p "$SYSROOT_DIR/usr/lib" + + sudo cp -r ./extracted/usr/include/* "$SYSROOT_DIR/usr/include/" + sudo cp -r ./extracted/usr/lib/aarch64-linux-gnu/* "$SYSROOT_DIR/usr/lib/" + - name: Prepare node_modules run: | cd source/binding/NodeJS diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b39a65e6cb..207e4a06a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -180,58 +180,6 @@ jobs: run: | sudo apt-get update -y sudo apt-get install -y ninja-build cmake ccache libpipewire-0.3-dev libdbus-1-dev - if [[ '${{ matrix.arch }}' == 'aarch64' ]]; then - # ------------------------------------------------------------------ - # ARM64 cross-compilation: inject PipeWire + D-Bus into x-tools sysroot - # - # Architecture pinning prevents 404 errors by restricting the default - # apt sources to amd64 only, then adding an explicit ARM64 Ports source. - # apt-get download dynamically resolves the dependency tree so it always - # fetches the latest package versions. - # ------------------------------------------------------------------ - - # 1. Enable arm64 architecture support - sudo dpkg --add-architecture arm64 - - # 2. Architecture pinning: force existing sources to serve amd64 only - sudo sed -i 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list 2>/dev/null || true - sudo sed -i 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list 2>/dev/null || true - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i 's/^Types: deb$/Types: deb\nArchitectures: amd64/g' /etc/apt/sources.list.d/ubuntu.sources - fi - - # 3. Inject dedicated ARM64 official Ports source - # NOTE: heredoc (cat < /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "URIs: http://ports.ubuntu.com/ubuntu-ports" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "Suites: noble noble-updates noble-security" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "Components: main universe restricted multiverse" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - sudo sh -c 'echo "Architectures: arm64" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' - - # 4. Update (no 404 since sources are pinned per-arch) - sudo apt-get update - - # 5. Download latest ARM64 .deb packages - mkdir -p arm64_deps && cd arm64_deps - apt-get download \ - libdbus-1-dev:arm64 libdbus-1-3:arm64 \ - libpipewire-0.3-dev:arm64 libpipewire-0.3-0t64:arm64 \ - libspa-0.2-dev:arm64 libspa-0.2-modules:arm64 - - # 6. Extract and inject into x-tools sysroot - mkdir -p ./extracted - for deb in *.deb; do - dpkg -x "$deb" ./extracted - done - - SYSROOT_DIR="${{ github.workspace }}/source/MaaUtils/MaaDeps/x-tools/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" - cp -r ./extracted/usr/include/* "$SYSROOT_DIR/usr/include/" - cp -r ./extracted/usr/lib/aarch64-linux-gnu/* "$SYSROOT_DIR/usr/lib/" - - cd .. - fi # https://github.com/MaaXYZ/MaaFramework/actions/runs/5643408179/job/15285186255 - uses: actions/checkout@v4 @@ -254,6 +202,43 @@ jobs: run: | python3 tools/maadeps-download.py ${{ matrix.arch == 'x86_64' && 'x64' || 'arm64' }}-linux + - name: Inject ARM64 Dependencies + if: matrix.arch == 'aarch64' + run: | + echo "Installing ARM64 cross-compile dependencies..." + sudo dpkg --add-architecture arm64 + sudo sed -i 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list 2>/dev/null || true + sudo sed -i 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list 2>/dev/null || true + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i 's/^Types: deb$/Types: deb\nArchitectures: amd64/g' /etc/apt/sources.list.d/ubuntu.sources + fi + + sudo sh -c 'echo "Types: deb" > /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "URIs: http://ports.ubuntu.com/ubuntu-ports" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Suites: noble noble-updates noble-security" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Components: main universe restricted multiverse" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + sudo sh -c 'echo "Architectures: arm64" >> /etc/apt/sources.list.d/ubuntu-arm64.sources' + + sudo apt-get update -y + + mkdir -p arm64_deps && cd arm64_deps + sudo apt-get download libdbus-1-dev:arm64 libdbus-1-3:arm64 libpipewire-0.3-dev:arm64 libpipewire-0.3-0t64:arm64 libspa-0.2-dev:arm64 libspa-0.2-modules:arm64 + + mkdir -p ../extracted + for deb in *.deb; do + dpkg -x "$deb" ../extracted + done + cd .. + + SYSROOT_DIR="${GITHUB_WORKSPACE}/source/MaaUtils/MaaDeps/x-tools/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + + # 确保目录结构存在后再拷贝 + sudo mkdir -p "$SYSROOT_DIR/usr/include" + sudo mkdir -p "$SYSROOT_DIR/usr/lib" + + sudo cp -r ./extracted/usr/include/* "$SYSROOT_DIR/usr/include/" + sudo cp -r ./extracted/usr/lib/aarch64-linux-gnu/* "$SYSROOT_DIR/usr/lib/" + - uses: pnpm/action-setup@v4 with: version: latest From e5c0ddd34a202f62f04aa294ee88d006b42d9361 Mon Sep 17 00:00:00 2001 From: Enderlava Date: Sun, 7 Jun 2026 02:14:04 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=E9=94=AE=E7=9B=98=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BB=A5=E5=8F=8A=E5=85=B6=E4=BB=96=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp | 2 +- source/MaaKWinControlUnit/Input/UInputController.cpp | 2 +- source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp b/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp index 866319e67a..2c76d46b51 100644 --- a/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp +++ b/source/MaaKWinControlUnit/API/KWinControlUnitAPI.cpp @@ -18,7 +18,7 @@ MaaKWinControlUnitHandle MaaKWinControlUnitCreate(const char* device_node, int s LogFunc << VAR(device_node) << VAR(screen_width) << VAR(screen_height); - if (!device_node) { + if (!device_node || device_node[0] == '\0') { LogError << "device_node is null or empty"; return nullptr; } diff --git a/source/MaaKWinControlUnit/Input/UInputController.cpp b/source/MaaKWinControlUnit/Input/UInputController.cpp index 8c94fece52..127e34bf2b 100644 --- a/source/MaaKWinControlUnit/Input/UInputController.cpp +++ b/source/MaaKWinControlUnit/Input/UInputController.cpp @@ -343,7 +343,7 @@ int UInputController::maa_to_linux_keycode(int key_code) } // ASCII digit mapping if (key_code >= '0' && key_code <= '9') { - return KEY_1 + (key_code - '1'); + return KEY_0 + (key_code - '0'); } // Common ASCII / control character mappings switch (key_code) { diff --git a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp index dcb86590a3..dd0066cda7 100644 --- a/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp +++ b/source/MaaKWinControlUnit/Screencap/PipeWireScreencap.cpp @@ -1047,9 +1047,9 @@ bool PipeWireScreencap::process_frame(const struct spa_buffer* spa_buf, cv::Mat& } else if (data.type == SPA_DATA_DmaBuf) { // DMABuf cannot be directly mapped via mmap; silently drop the frame - // and return true to indicate "no error, but skip this frame". + // and return false to let pw_on_stream_process skip this frame. LogWarn << "DMABUF buffer type received, dropping frame (EGL import not supported)"; - return true; + return false; } else { LogError << "Unsupported buffer type: " << data.type;