From 4e844bf3079f848db6096c935390fc299ef92b2d Mon Sep 17 00:00:00 2001 From: Nick Bolton Date: Fri, 30 Aug 2024 15:53:25 +0100 Subject: [PATCH] Wayland support (port Red Hat `libei` and `libportal` impl) (#7449) * Add libei and libportal * Port libei and libportal code by Peter Hutterer and Olivier Fourdan * Add Peter Hutterer and Olivier Fourdan to important devs list * Improve error handling for libei and libportal builds * Checkout libportal tags/0.7.1 * Hack out the gi-docgen clone * Remove new submodules in favor of using ExternalProject_Add * Remove submodule dirs * Use ExternalProject_Add instead of submodules * Fixed namespace * Hack to work around type libportal causing type conflicts * Add log helper functions * Use original log functions * Switch to FetchContent, use libportal a1530a9 (unreleased) and use camelCase member names for consistency * Restore a few events (much more work required) * Add TODOs for supporting multiple lib versions * Revert "Switch to FetchContent, use libportal a1530a9 (unreleased) and use camelCase member names for consistency" This reverts commit 610cebb5b6a08282eee68f4424fcdbe9eaab4bf9. * Simplify cmake config by removing builds for libei and libportal (will do this in `install_deps.py` instead) * Remove submodules * Remove .gitmodules * Use meson to build subprojects * Update copyright with Peter Hutterer's guidance * Use meson for installing deps * Fixed typo in tag name * Remove submodules * Remove old submodules * Fixed libei name * Defaults for pugixml and gtest depending on whether source exists in subprojects * Ignore some subproject dirs * Make deps OS-specific * Move python deps to pyproject * Only compile and install on Linux with Meson * Revert "Move python deps to pyproject" This reverts commit 92c8a287b8376a4d166058c85f1d6081f6fdb423. * Add ninja to brewfile * Add python3-attr * Restore original coverage config * Add ninja for meson * Include meson in same try-except * Fixed ninja dep name * Move libs to correct oS * Fixed include for wintoast * Disable docs for libportal * More options for libei and libportal * Fixed option for libei * Use ninja directly to install * Revert "Use ninja directly to install" This reverts commit c926d78ba483406a55acd10fb157c39e13f90b71. * Meson install verbose * Prints stdout/stderr * Remove `from None` * Remove submodules that somehow crept back in?! * Prepend sudo if exists * Add libei deps for all distros * Fixed Fedora package name * Add more deps for other distros * Add more libs (including pugixml) * Fix lib name * Drop -u from pacman * Add vala to rhel * Make libportal optional * Make portal link optional * Remove example code * Always use system pugixml * Disable interactive apt through install_deps.py * Revert "Disable interactive apt through install_deps.py" This reverts commit 5bbc8fd689182447c79b81db16c961b98361a292. * Set DEBIAN_FRONTEND in workflows * Set DEBIAN_FRONTEND in CodeQL workflow * Add gtest dep * Add bundled libei dep * Add libei dep to Arch * Use `googletest` on openSUSE * Add gmock dep * Remove gtest dep from openSUSE * Add libei on Fedora * Bundle libei for older Linux distros * Disable libei dep for RPM * Also bundle symlink to .so * Use ${CMAKE_INSTALL_LIBDIR} * Rename libei to fix openSUSE * List installed files * Add libei-devel to openSUSE * Add googletest-devel to openSUSE * Remove manual deps (probably resolved automatically) * Remove googletest from openSUSE (doesn't provide google mock) * Only add Portal* if libportal found * WIP - Partial work on using old events system :'( * Add deps install commands for subprojects * Solved more compile issues related to events system, threads, etc * Fixed bad config for adding x, ei, portal sources * Remove redundant deps * Remove (another) redundant dep * Fixed pacman command * Add Ubuntu and Linux Mint libei deps * Fixed Ubuntu and Linux Mint libei deps aliases * Only iterate subprojects if not None * Add rhel, rocky, and alma for libei * Make rhel-like deps same as fedora again * Build libportal on rhel-like * Re-enable meson rhel-like for libportal * Remove dbus-python * Make libportal optional (for rhel-like) * Handle ei event queue results * Re-introduce libportal * Print libei and libportal versions * Add ei/portal args and client screen * Switch --use args to --no * Don't build libei/libportal on older distros as it's pointless * Make libei and libportal optional * Add Debian 13 runner * Make some packages optional * Remove subprojects * Improve comment * Add comment for libportal * Improve comment * Add Debian 13 runner * Make optional... optional * Change continuation stripper to remove newline and continuation char * Make command strip more uniform * Fixed help var syntax * Fixed libei linking * Ensure `kHelpNoWayland` is always defined * Improve help message * Fixed Debian 13 runner name * Include sstream and use const var * Update ChangeLog * Remove Wayland block * Return new timer * Make tray icon logging verbose * Fixed arg parser for wayland args * Fixed init of EI screen * Fixed lint issues * Update README to indicate Wayland support in GNOME 46 * Add missing word * Fixed comment positions * Automate CI env * Tone down debug log messages * Add missing comma * Remove redundant log line --- .github/docker/archlinux/Dockerfile | 2 +- .github/docker/debian/Dockerfile | 2 +- .github/docker/fedora/Dockerfile | 2 +- .github/docker/opensuse/Dockerfile | 2 +- .github/workflows/build-containers.yml | 6 + .github/workflows/ci.yml | 18 +- .github/workflows/codeql-analysis.yml | 5 +- .github/workflows/sonarcloud-analysis.yml | 9 +- .github/workflows/valgrind-analysis.yml | 5 +- .gitmodules | 9 - Brewfile | 1 + ChangeLog | 1 + README.md | 4 +- cmake/Libraries.cmake | 159 +++- cmake/Packaging.cmake | 6 +- config.yaml | 71 +- cspell.json | 6 + ext/WinToast | 1 - ext/googletest | 1 - ext/pugixml | 1 - meson.build | 37 + meson_options.txt | 2 + res/config.h.in | 12 + res/dist/arch/PKGBUILD.in | 2 + res/doxygen.cfg.in | 2 +- scripts/install_deps.py | 107 ++- scripts/lib/cmd_utils.py | 23 +- scripts/lib/config.py | 26 +- scripts/lib/linux.py | 68 +- scripts/lib/meson.py | 25 + scripts/lint_cmake.py | 2 +- scripts/package.py | 14 +- scripts/pyproject.toml | 1 + src/gui/src/AboutDialog.cpp | 58 +- src/gui/src/SetupWizardBlocker.cpp | 58 -- src/gui/src/SetupWizardBlocker.h | 36 - src/gui/src/SetupWizardBlocker.ui | 129 --- src/gui/src/main.cpp | 9 - src/lib/base/EventQueue.cpp | 2 + src/lib/base/EventQueue.h | 3 + src/lib/base/EventTypes.cpp | 7 + src/lib/base/EventTypes.h | 28 + src/lib/base/IEventQueue.h | 2 + src/lib/base/Log.h | 13 + src/lib/gui/TrayIcon.cpp | 4 +- src/lib/platform/CMakeLists.txt | 43 +- src/lib/platform/EiEventQueueBuffer.cpp | 162 ++++ src/lib/platform/EiEventQueueBuffer.h | 59 ++ src/lib/platform/EiKeyState.cpp | 282 ++++++ src/lib/platform/EiKeyState.h | 63 ++ src/lib/platform/EiScreen.cpp | 852 ++++++++++++++++++ src/lib/platform/EiScreen.h | 201 +++++ src/lib/platform/PortalInputCapture.cpp | 409 +++++++++ src/lib/platform/PortalInputCapture.h | 109 +++ src/lib/platform/PortalRemoteDesktop.cpp | 204 +++++ src/lib/platform/PortalRemoteDesktop.h | 75 ++ src/lib/synergy/App.h | 26 +- src/lib/synergy/ArgParser.cpp | 19 +- src/lib/synergy/ArgParser.h | 4 +- src/lib/synergy/ArgsBase.h | 28 +- src/lib/synergy/CMakeLists.txt | 19 +- src/lib/synergy/ClientApp.cpp | 106 ++- src/lib/synergy/IPrimaryScreen.cpp | 10 + src/lib/synergy/IPrimaryScreen.h | 8 + src/lib/synergy/ServerApp.cpp | 116 ++- src/lib/synergy/mouse_types.h | 1 + src/test/CMakeLists.txt | 18 +- src/test/mock/synergy/MockEventQueue.h | 1 + subprojects/.gitignore | 12 + subprojects/gtest.wrap | 16 + subprojects/libei.wrap | 3 + subprojects/libportal.wrap | 3 + .../packagefiles/wintoast-patch/meson.build | 5 + subprojects/wintoast.wrap | 6 + 74 files changed, 3331 insertions(+), 510 deletions(-) delete mode 100644 .gitmodules delete mode 160000 ext/WinToast delete mode 160000 ext/googletest delete mode 160000 ext/pugixml create mode 100644 meson.build create mode 100644 meson_options.txt create mode 100644 scripts/lib/meson.py delete mode 100644 src/gui/src/SetupWizardBlocker.cpp delete mode 100644 src/gui/src/SetupWizardBlocker.h delete mode 100644 src/gui/src/SetupWizardBlocker.ui create mode 100644 src/lib/platform/EiEventQueueBuffer.cpp create mode 100644 src/lib/platform/EiEventQueueBuffer.h create mode 100644 src/lib/platform/EiKeyState.cpp create mode 100644 src/lib/platform/EiKeyState.h create mode 100644 src/lib/platform/EiScreen.cpp create mode 100644 src/lib/platform/EiScreen.h create mode 100644 src/lib/platform/PortalInputCapture.cpp create mode 100644 src/lib/platform/PortalInputCapture.h create mode 100644 src/lib/platform/PortalRemoteDesktop.cpp create mode 100644 src/lib/platform/PortalRemoteDesktop.h create mode 100644 subprojects/.gitignore create mode 100644 subprojects/gtest.wrap create mode 100644 subprojects/libei.wrap create mode 100644 subprojects/libportal.wrap create mode 100644 subprojects/packagefiles/wintoast-patch/meson.build create mode 100644 subprojects/wintoast.wrap diff --git a/.github/docker/archlinux/Dockerfile b/.github/docker/archlinux/Dockerfile index 1431dcbec..172d2f182 100644 --- a/.github/docker/archlinux/Dockerfile +++ b/.github/docker/archlinux/Dockerfile @@ -13,5 +13,5 @@ RUN useradd -m build WORKDIR /app RUN --mount=type=bind,target=/app,rw \ - ./scripts/install_deps.py --ci-env && \ + ./scripts/install_deps.py && \ pacman -Scc --noconfirm diff --git a/.github/docker/debian/Dockerfile b/.github/docker/debian/Dockerfile index ea9322cbd..28a24804d 100644 --- a/.github/docker/debian/Dockerfile +++ b/.github/docker/debian/Dockerfile @@ -13,5 +13,5 @@ RUN apt update && \ WORKDIR /app RUN --mount=type=bind,target=/app,rw \ - ./scripts/install_deps.py --ci-env && \ + ./scripts/install_deps.py && \ apt clean diff --git a/.github/docker/fedora/Dockerfile b/.github/docker/fedora/Dockerfile index a2c390c0e..819fd01ce 100644 --- a/.github/docker/fedora/Dockerfile +++ b/.github/docker/fedora/Dockerfile @@ -12,5 +12,5 @@ RUN dnf upgrade -y && \ WORKDIR /app RUN --mount=type=bind,target=/app,rw \ - ./scripts/install_deps.py --ci-env && \ + ./scripts/install_deps.py && \ dnf clean all diff --git a/.github/docker/opensuse/Dockerfile b/.github/docker/opensuse/Dockerfile index 4236521f1..c075a1e12 100644 --- a/.github/docker/opensuse/Dockerfile +++ b/.github/docker/opensuse/Dockerfile @@ -13,5 +13,5 @@ RUN zypper refresh && \ WORKDIR /app RUN --mount=type=bind,target=/app,rw \ - ./scripts/install_deps.py --ci-env && \ + ./scripts/install_deps.py && \ zypper clean --all diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index bf2cf056e..6968bd36f 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -26,6 +26,12 @@ jobs: matrix: os: + - name: debian-13-amd64 + runs-on: ubuntu-latest + config-dir: debian + base-image: debian:trixie-slim + platform: amd64 + - name: debian-12-amd64 runs-on: ubuntu-latest config-dir: debian diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14d5ee122..cf0c6d749 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,7 @@ jobs: key: ${{ runner.os }}-deps-${{ hashFiles('config.yaml') }} - name: Install dependencies - run: python ./scripts/install_deps.py --ci-env + run: python ./scripts/install_deps.py - name: Setup VC++ environment uses: ilammy/msvc-dev-cmd@v1 @@ -156,7 +156,7 @@ jobs: key: ${{ runner.os }}-deps-${{ hashFiles('config.yaml') }} - name: Install dependencies - run: ./scripts/install_deps.py --ci-env + run: ./scripts/install_deps.py - name: Configure run: cmake -B build --preset=macos-release @@ -199,10 +199,6 @@ jobs: container: ${{ matrix.distro.container }} timeout-minutes: 20 - env: - # Prevent apt prompting for input. - DEBIAN_FRONTEND: noninteractive - strategy: # Normally, we want to fail fast, but in this case we shouldn't since one distro may # fail due to transient issues unrelated to the build. @@ -210,6 +206,11 @@ jobs: matrix: distro: + - name: debian-13-amd64 + container: symless/synergy-core:debian-13-amd64 + runs-on: ubuntu-latest + extra-packages: true + - name: debian-12-arm64 container: symless/synergy-core:debian-12-arm64 runs-on: ubuntu-24.04-8-core-arm64 @@ -272,7 +273,10 @@ jobs: run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Install dependencies - run: ./scripts/install_deps.py --ci-env + run: ./scripts/install_deps.py + env: + # Prevent apt prompting for input. + DEBIAN_FRONTEND: noninteractive - name: Configure run: cmake -B build --preset=linux-release diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f65b4bb27..fdf621eca 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,10 @@ jobs: run: git config --global --add safe.directory $GITHUB_WORKSPACE - name: Install dependencies - run: ./scripts/install_deps.py --ci-env + run: ./scripts/install_deps.py + env: + # Prevent apt prompting for input. + DEBIAN_FRONTEND: noninteractive - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/sonarcloud-analysis.yml b/.github/workflows/sonarcloud-analysis.yml index 212631bd1..739d36389 100644 --- a/.github/workflows/sonarcloud-analysis.yml +++ b/.github/workflows/sonarcloud-analysis.yml @@ -39,9 +39,12 @@ jobs: - name: Install dependencies run: | - ./scripts/install_deps.py --ci-env && + ./scripts/install_deps.py && apt install curl unzip -y && pip install gcovr + env: + # Prevent apt prompting for input. + DEBIAN_FRONTEND: noninteractive - name: Install SonarScanner run: | @@ -102,8 +105,8 @@ jobs: -Dsonar.projectKey=symless_synergy-core \ -Dsonar.sources=scripts,src/cmd,src/gui,src/lib \ -Dsonar.tests=src/test \ - -Dsonar.exclusions=ext/**,build/** \ - -Dsonar.coverage.exclusions=ext/**,scripts/**,src/test/** \ + -Dsonar.exclusions=subprojects/**,build/** \ + -Dsonar.coverage.exclusions=subprojects/**,scripts/**,src/test/** \ -Dsonar.cpd.exclusions=**/*Test*.cpp \ -Dsonar.host.url=https://sonarcloud.io \ -Dsonar.coverageReportPaths=${{ steps.coverage-paths.outputs.csv }} \ diff --git a/.github/workflows/valgrind-analysis.yml b/.github/workflows/valgrind-analysis.yml index 8a61e7b34..aca854917 100644 --- a/.github/workflows/valgrind-analysis.yml +++ b/.github/workflows/valgrind-analysis.yml @@ -26,8 +26,11 @@ jobs: - name: Install dependencies run: | - ./scripts/install_deps.py --ci-env && + ./scripts/install_deps.py && apt install valgrind -y + env: + # Prevent apt prompting for input. + DEBIAN_FRONTEND: noninteractive - name: Configure run: cmake -B build --preset=linux-release diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 1b6d52f29..000000000 --- a/.gitmodules +++ /dev/null @@ -1,9 +0,0 @@ -[submodule "ext/googletest"] - path = ext/googletest - url = https://github.com/google/googletest.git -[submodule "ext/WinToast"] - path = ext/WinToast - url = https://github.com/mohabouje/WinToast -[submodule "ext/pugixml"] - path = ext/pugixml - url = https://github.com/zeux/pugixml diff --git a/Brewfile b/Brewfile index 07574f885..0c7834c1b 100644 --- a/Brewfile +++ b/Brewfile @@ -1,3 +1,4 @@ brew 'make' brew 'cmake' brew 'openssl' +brew 'ninja' diff --git a/ChangeLog b/ChangeLog index 21875ae9a..06c336cfb 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,7 @@ Enhancements: +- #7449 Wayland support (port Red Hat libei and libportal impl) - #7448 Only add PR comments for internal PR - #7445 Update `config.yaml` to support Linux Mint build target diff --git a/README.md b/README.md index 04062822a..845cc9051 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ This project contains the source code for _Synergy 1 Community Edition_ which is actively maintained, free to use, and does not require a license or serial key. +**Wayland support:** Wayland is supported (GNOME 46 is required). + ![Synergy 1 Community Edition](https://github.com/user-attachments/assets/faf5bd69-336c-4bd0-ace3-e911f199d961) To use the community edition, install the `synergy` package with your favorite package manager or build it yourself using the Developer Quick Start instructions below. @@ -95,7 +97,7 @@ sudo dnf install synergy *Arch, Manjaro, etc:* ``` -sudo pacman -Syu synergy +sudo pacman -S synergy ``` *Repology:* diff --git a/cmake/Libraries.cmake b/cmake/Libraries.cmake index 406f63749..f4af3f78d 100644 --- a/cmake/Libraries.cmake +++ b/cmake/Libraries.cmake @@ -1,3 +1,6 @@ +set(LIBEI_MIN_VERSION 1.2.1) +set(LIBPORTAL_MIN_VERSION 0.6) + macro(configure_libs) set(libs) @@ -9,8 +12,8 @@ macro(configure_libs) config_qt() configure_openssl() - update_submodules() - configure_test_libs() + configure_gtest() + configure_coverage() endmacro() @@ -99,6 +102,7 @@ macro(configure_unix_libs) configure_mac_libs() else() configure_xorg_libs() + configure_wayland_libs() endif() # For config.h, set some static values; it may be a good idea to make these @@ -155,11 +159,79 @@ macro(configure_mac_libs) endmacro() +macro(configure_wayland_libs) + + include(FindPkgConfig) + + pkg_check_modules(LIBEI QUIET "libei-1.0 >= ${LIBEI_MIN_VERSION}") + if(LIBEI_FOUND) + message(STATUS "libei version: ${LIBEI_VERSION}") + add_definitions(-DWINAPI_LIBEI=1) + include_directories(${LIBEI_INCLUDE_DIRS}) + else() + message( + WARNING + "libei >= ${LIBEI_MIN_VERSION} not found, Wayland support will be disabled." + ) + endif() + + pkg_check_modules(LIBPORTAL QUIET "libportal >= ${LIBPORTAL_MIN_VERSION}") + if(LIBPORTAL_FOUND) + message(STATUS "libportal version: ${LIBPORTAL_VERSION}") + add_definitions(-DWINAPI_LIBPORTAL=1) + include_directories(${LIBPORTAL_INCLUDE_DIRS}) + check_libportal() + else() + message( + WARNING + "libportal >= ${LIBPORTAL_MIN_VERSION} not found, some Wayland features will be disabled." + ) + endif() + + pkg_check_modules(LIBXKBCOMMON REQUIRED xkbcommon) + pkg_check_modules(GLIB2 REQUIRED glib-2.0 gio-2.0) + find_library(LIBM m) + include_directories(${LIBXKBCOMMON_INCLUDE_DIRS} ${GLIB2_INCLUDE_DIRS} + ${LIBM_INCLUDE_DIRS}) + +endmacro() + +macro(check_libportal) + # libportal 0.7 has xdp_session_connect_to_eis but it doesn't have remote desktop session restore or + # the inputcapture code, so let's check for explicit functions that bits depending on what we have + include(CMakePushCheckState) + include(CheckCXXSourceCompiles) + cmake_push_check_state(RESET) + set(CMAKE_REQUIRED_INCLUDES + "${CMAKE_REQUIRED_INCLUDES};${LIBPORTAL_INCLUDE_DIRS};${GLIB2_INCLUDE_DIRS}" + ) + set(CMAKE_REQUIRED_LIBRARIES + "${CMAKE_REQUIRED_LIBRARIES};${LIBPORTAL_LINK_LIBRARIES};${GLIB2_LINK_LIBRARIES}" + ) + check_symbol_exists(xdp_session_connect_to_eis "libportal/portal.h" + HAVE_LIBPORTAL_SESSION_CONNECT_TO_EIS) + check_symbol_exists( + xdp_portal_create_remote_desktop_session_full "libportal/portal.h" + HAVE_LIBPORTAL_CREATE_REMOTE_DESKTOP_SESSION_FULL) + check_symbol_exists(xdp_input_capture_session_connect_to_eis + "libportal/inputcapture.h" HAVE_LIBPORTAL_INPUTCAPTURE) + + # check_symbol_exists can’t check for enum values + check_cxx_source_compiles( + "#include + int main() { XdpOutputType out = XDP_OUTPUT_NONE; } + " HAVE_LIBPORTAL_OUTPUT_NONE) + cmake_pop_check_state() + +endmacro() + # # X.org/X11 for Linux, BSD, etc # macro(configure_xorg_libs) + find_package(pugixml REQUIRED) + # Add include dir for BSD (posix uses /usr/include/) set(CMAKE_INCLUDE_PATH "${CMAKE_INCLUDE_PATH}:/usr/local/include") @@ -256,6 +328,8 @@ endmacro() # macro(configure_windows_libs) + configure_wintoast() + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MP /D _BIND_TO_CURRENT_VCLIBS_VERSION=1") set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} /MD /O2 /Ob2") @@ -306,18 +380,54 @@ macro(configure_openssl) find_package(OpenSSL REQUIRED) endmacro() -macro(configure_test_libs) +macro(configure_gtest) - if(BUILD_TESTS AND NOT EXISTS - "${PROJECT_SOURCE_DIR}/ext/googletest/CMakeLists.txt") - message(FATAL_ERROR "Git submodule for Google Test is missing") + file(GLOB gtest_base_dir ${PROJECT_SOURCE_DIR}/subprojects/googletest-*) + if(gtest_base_dir) + set(DEFAULT_SYSTEM_GTEST OFF) + else() + set(DEFAULT_SYSTEM_GTEST ON) endif() + # Arch Linux package maintainers: + # We do care about not bundling libs and didn't mean to cause upset. We made some mistakes + # and we're trying to put that right. + # The comment "They BUNDLE a fucking zip for cryptopp" in synergy.git/PKGBUILD is only + # relevant to a very version of old the code, so the comment should probably be removed. + # If there are any problems like this in future, please do feel free send us a patch! :) + option(SYSTEM_GTEST "Use system GoogleTest" ${DEFAULT_SYSTEM_GTEST}) + if(SYSTEM_GTEST) + message(STATUS "Using system GoogleTest") + find_package(GTest REQUIRED) + set(GTEST_LIBS GTest::GTest GTest::Main) + else() + message(STATUS "Building GoogleTest") + set(gtest_dir ${gtest_base_dir}/googletest) + set(gmock_dir ${gtest_base_dir}/googlemock) + include_directories(${gtest_dir} ${gmock_dir} ${gtest_dir}/include + ${gmock_dir}/include) + + add_library(gtest STATIC ${gtest_dir}/src/gtest-all.cc) + add_library(gmock STATIC ${gmock_dir}/src/gmock-all.cc) + + if(UNIX) + # Ignore noisy GoogleTest warnings + set_target_properties(gtest PROPERTIES COMPILE_FLAGS "-w") + set_target_properties(gmock PROPERTIES COMPILE_FLAGS "-w") + endif() + + set(GTEST_LIBS gtest gmock) + endif() + +endmacro() + +macro(configure_coverage) + if(ENABLE_COVERAGE) message(STATUS "Enabling code coverage") include(cmake/CodeCoverage.cmake) append_coverage_compiler_flags() - set(test_exclude ext/* build/* src/test/*) + set(test_exclude subprojects/* build/* src/test/*) set(test_src ${PROJECT_SOURCE_DIR}/src) setup_target_for_coverage_gcovr_xml( @@ -343,35 +453,6 @@ macro(configure_test_libs) else() message(STATUS "Code coverage is disabled") endif() - - include_directories(BEFORE SYSTEM - ${PROJECT_SOURCE_DIR}/ext/googletest/googletest/include) -endmacro() - -macro(update_submodules) - - find_package(Git QUIET) - - if(GIT_FOUND AND EXISTS "${PROJECT_SOURCE_DIR}/.git") - - option(GIT_SUBMODULE "Check submodules during build" ON) - - if(GIT_SUBMODULE) - - message(STATUS "Submodule update") - execute_process( - COMMAND ${GIT_EXECUTABLE} submodule update --init --recursive - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} - RESULT_VARIABLE GIT_SUBMODULE_RESULT) - - if(NOT GIT_SUBMODULE_RESULT EQUAL "0") - message( - FATAL_ERROR "Git submodule update failed: ${GIT_SUBMODULE_RESULT}") - endif() - - endif() - endif() - endmacro() macro(configure_python) @@ -414,3 +495,9 @@ function(find_openssl_dir_win32 result) PARENT_SCOPE) endfunction() + +macro(configure_wintoast) + # WinToast is a pretty niche library, and there doesn't seem to be a package for it. + file(GLOB WINTOAST_DIR ${CMAKE_SOURCE_DIR}/subprojects/WinToast-*) + include_directories(${WINTOAST_DIR}/include) +endmacro() diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index a48847ac5..378fe0e35 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -91,14 +91,14 @@ macro(configure_linux_packaging) set(CPACK_DEBIAN_PACKAGE_SECTION "utils") set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) + set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") + set(CPACK_RPM_PACKAGE_GROUP "Applications/System") + # HACK: The GUI depends on the Qt6 QPA plugins package, but that's not picked # up by shlibdeps on Ubuntu 22 (though not a problem on Ubuntu 24 and Debian # 12), so we must add it manually. set(CPACK_DEBIAN_PACKAGE_DEPENDS "qt6-qpa-plugins") - set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") - set(CPACK_RPM_PACKAGE_GROUP "Applications/System") - # The default for CMake seems to be /usr/local, which seems uncommon. While # the default /usr/local prefix causes the app to appear on Debian and Fedora, # it doesn't seem to appear on Arch Linux. Setting the prefix to /usr seems to diff --git a/config.yaml b/config.yaml index a7421f0d9..b1e2424cc 100644 --- a/config.yaml +++ b/config.yaml @@ -28,7 +28,9 @@ config: sudo apt-get install -y \ cmake \ make \ + ninja-build \ g++ \ + file \ xorg-dev \ libx11-dev \ libxtst-dev \ @@ -40,7 +42,12 @@ config: qt6-base-dev \ qt6-tools-dev \ libgtk-3-dev \ - file + libgtest-dev \ + libgmock-dev \ + libpugixml-dev \ + libei-dev \ + libportal-dev + optional: [libei-dev, libportal-dev] linuxmint: <<: *debian @@ -55,7 +62,9 @@ config: sudo dnf install -y \ cmake \ make \ + ninja-build \ gcc-c++ \ + rpm-build \ openssl-devel \ glib2-devel \ gdk-pixbuf2-devel \ @@ -65,7 +74,12 @@ config: qt6-qtbase-devel \ qt6-qttools-devel \ gtk3-devel \ - rpm-build + gtest-devel \ + gmock-devel \ + pugixml-devel \ + libei-devel \ + libportal-devel + optional: [libei-devel, libportal-devel] # RHEL is not actually supported yet, since it doesn't have Qt6 libs. # We simply use it as a base for Alma Linux and Rocky Linux. @@ -94,7 +108,9 @@ config: command: sudo zypper install -y --force-resolution \ cmake \ make \ + ninja \ gcc-c++ \ + rpm-build \ libopenssl-devel \ glib2-devel \ gdk-pixbuf-devel \ @@ -104,13 +120,16 @@ config: qt6-base-devel \ qt6-tools-devel \ gtk3-devel \ - rpm-build + pugixml-devel \ + libei-devel \ + libportal-devel arch: &arch dependencies: - command: sudo pacman -Syu --noconfirm \ + command: sudo pacman -Sy --noconfirm \ base-devel \ cmake \ + ninja \ gcc \ openssl \ glib2 \ @@ -118,9 +137,53 @@ config: libxtst \ libnotify \ libxkbfile \ + gtest \ + pugixml \ + libei \ + libportal \ qt6-base \ qt6-tools \ gtk3 manjaro: <<: *arch + + subprojects: + libei: + dependencies: + debian: &debian_libei | + sudo apt-get install -y \ + python3-attr \ + python3-jinja2 \ + libsystemd-dev + + ubuntu: *debian_libei + linuxmint: *debian_libei + + fedora: &fedora_libei | + sudo dnf install -y \ + python3-attrs \ + python3-jinja2 \ + systemd-devel + + rhel: *fedora_libei + rocky: *fedora_libei + almalinux: *fedora_libei + + libportal: + dependencies: + debian: &debian_libportal | + sudo apt-get install -y \ + python3-dbusmock \ + python3-pytest \ + valac \ + protobuf-c-compiler \ + protobuf-compiler \ + libglib2.0 \ + libgtk-3-dev \ + libprotobuf-c-dev \ + libsystemd-dev \ + libgirepository1.0-dev + + ubuntu: *debian_libportal + linuxmint: *debian_libportal diff --git a/cspell.json b/cspell.json index b127be51f..4db469625 100644 --- a/cspell.json +++ b/cspell.json @@ -17,16 +17,20 @@ "dotenv", "Evenson", "Feder", + "Fourdan", "gdrive", "Hadzhylov", "Hetu", "hotspots", + "Hutterer", "ifdef", "integtests", "keychain", "Keychains", "Kutytska", "Lanz", + "libei", + "libportal", "LLDB", "Lysytsia", "macdeployqt", @@ -49,6 +53,8 @@ "Schoeneman", "Serhii", "Sorin", + "subproject", + "subprojects", "synergyc", "synergyd", "synergys", diff --git a/ext/WinToast b/ext/WinToast deleted file mode 160000 index 8abb85b44..000000000 --- a/ext/WinToast +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8abb85b44cb2100dba45c3f3f8fe981fcf300b71 diff --git a/ext/googletest b/ext/googletest deleted file mode 160000 index e39786088..000000000 --- a/ext/googletest +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e39786088138f2749d64e9e90e0f9902daa77c40 diff --git a/ext/pugixml b/ext/pugixml deleted file mode 160000 index 9e382f980..000000000 --- a/ext/pugixml +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9e382f98076e57581fcc61323728443374889646 diff --git a/meson.build b/meson.build new file mode 100644 index 000000000..bd548f8de --- /dev/null +++ b/meson.build @@ -0,0 +1,37 @@ +# For now, we're only using Meson to resolve dependencies. CMake is called separately. +# In future, we may completely replace CMake with Meson. + +project('synergy', 'cpp') + +gtest = dependency('gtest', required: false) +if not gtest.found() + subproject('gtest') +endif + +if host_machine.system() == 'windows' + wintoast = dependency('wintoast', required: false) + if not wintoast.found() + subproject('wintoast') + endif +endif + +if host_machine.system() == 'linux' + + system_libei = get_option('system_libei') + if system_libei + dependency('libei-1.0', required: false) + else + # Using the subproject is only useful for development; it's not intended for normal use. + # GNOME46 or above is required as this has the required bits for libei. + # Building on anything older is pointless as you won't be able to actually connect to anything. + subproject('libei', default_options: ['tests=disabled', 'liboeffis=disabled']) + endif + + system_libportal = get_option('system_libportal') + if system_libportal + dependency('libportal', required: false) + else + # Using the subproject is only useful for development; it's not intended for normal use. + subproject('libportal', default_options: ['docs=false', 'backend-gtk3=enabled']) + endif +endif diff --git a/meson_options.txt b/meson_options.txt new file mode 100644 index 000000000..f6261128c --- /dev/null +++ b/meson_options.txt @@ -0,0 +1,2 @@ +option('system_libportal', type: 'boolean', value: true, description: 'Use system libportal') +option('system_libei', type: 'boolean', value: true, description: 'Use system libei') diff --git a/res/config.h.in b/res/config.h.in index 218906f36..e2ff66492 100644 --- a/res/config.h.in +++ b/res/config.h.in @@ -168,3 +168,15 @@ /* Define to `unsigned int` if does not define. */ #cmakedefine size_t ${size_t} + +/* Define if libportal has xdp_session_connect_to_eis */ +#cmakedefine HAVE_LIBPORTAL_SESSION_CONNECT_TO_EIS ${HAVE_LIBPORTAL_SESSION_CONNECT_TO_EIS} + +/* Define if libportal has xdp_portal_create_remote_desktop_session_full */ +#cmakedefine HAVE_LIBPORTAL_CREATE_REMOTE_DESKTOP_SESSION_FULL ${HAVE_LIBPORTAL_CREATE_REMOTE_DESKTOP_SESSION_FULL} + +/* Define if libportal has inputcapture support */ +#cmakedefine HAVE_LIBPORTAL_INPUTCAPTURE ${HAVE_LIBPORTAL_INPUTCAPTURE} + +/* Define if libei ei_device_start_emulating takes a sequence number */ +#cmakedefine HAVE_LIBEI_SEQUENCE_NUMBER ${HAVE_LIBEI_SEQUENCE_NUMBER} diff --git a/res/dist/arch/PKGBUILD.in b/res/dist/arch/PKGBUILD.in index 23d51a502..85e27d5be 100644 --- a/res/dist/arch/PKGBUILD.in +++ b/res/dist/arch/PKGBUILD.in @@ -22,6 +22,8 @@ depends=( 'hicolor-icon-theme' 'qt6-base' 'qt6-tools' + 'libei' + 'libportal' ) conflicts=('synergy-git' 'synergy1-bin' 'synergy2-bin' 'synergy3-bin') options=('!debug') diff --git a/res/doxygen.cfg.in b/res/doxygen.cfg.in index ed7fae716..ad7d18783 100644 --- a/res/doxygen.cfg.in +++ b/res/doxygen.cfg.in @@ -627,7 +627,7 @@ EXCLUDE_SYMLINKS = NO EXCLUDE_PATTERNS = */.svn/* \ */.git/* \ - */ext/* \ + */subprojects/* \ */src/gui/* \ */src/test/* diff --git a/scripts/install_deps.py b/scripts/install_deps.py index b4595bbec..5179bab7a 100755 --- a/scripts/install_deps.py +++ b/scripts/install_deps.py @@ -5,25 +5,37 @@ import lib.env as env import lib.cmd_utils as cmd_utils import lib.qt_utils as qt_utils import lib.github as github +import lib.meson as meson path_env_var = "PATH" cmake_prefix_env_var = "CMAKE_PREFIX_PATH" def main(): + is_ci = os.getenv("CI") is not None + if is_ci: + print("CI environment detected") + parser = argparse.ArgumentParser() parser.add_argument( "--pause-on-exit", action="store_true", help="Useful on Windows" ) - parser.add_argument( - "--only-python", - action="store_true", - help="Only install Python dependencies", - ) parser.add_argument( "--ci-env", action="store_true", - help="Set if running in CI environment", + help="Useful for faking CI env (defaults to true in CI env)", + default=is_ci, + ) + parser.add_argument( + "--skip-system", + action="store_true", + help="Do not install system dependencies (apt, dnf, etc)", + ) + parser.add_argument( + "--skip-meson", action="store_true", help="Do not setup and install with Meson" + ) + parser.add_argument( + "--subproject", type=str, help="Sub-project to install dependencies for" ) args = parser.parse_args() @@ -32,13 +44,11 @@ def main(): env.install_requirements() error = False - if not args.only_python: - try: - deps = Dependencies(args.ci_env) - deps.install() - except Exception: - traceback.print_exc() - error = True + try: + run(args) + except Exception: + traceback.print_exc() + error = True if args.pause_on_exit: input("Press enter to continue...") @@ -47,6 +57,34 @@ def main(): sys.exit(1) +def run(args): + if args.subproject: + deps = SubprojectDependencies(args.subproject) + deps.install() + return + + if not args.skip_system: + deps = Dependencies(args.ci_env) + deps.install() + + if not args.skip_meson: + run_meson() + + +# It's a bit weird to use Meson just for installing deps, but it's a stopgap until +# we fully switch from CMake to Meson. For the meantime, Meson will install the deps +# so that CMake can find them easily. Once we switch to Meson, it might be possible for +# Meson handle the deps resolution, so that we won't need to install them on the system. +def run_meson(): + meson.setup() + + # Only compile and install on Linux for now, since we're only using Meson to fetch + # the deps on Windows and macOS. + if env.is_linux(): + meson.compile() + meson.install() + + class Dependencies: def __init__(self, ci_env): @@ -55,9 +93,6 @@ class Dependencies: self.config = Config() self.ci_env = ci_env - if self.ci_env: - print("CI environment detected") - def install(self): """Installs dependencies for the current platform.""" @@ -144,9 +179,49 @@ class Dependencies: linux.run_command(command_pre, check) command = self.config.get_os_deps_command(linux_distro=distro) + optional = self.config.get_os_deps_value( + "optional", linux_distro=distro, required=False + ) + for optional_package in optional or []: + if not linux.is_package_available(optional_package): + print(f"Optional package not found, stripping: {optional_package}") + command = command.replace(optional_package, "") + print("Running dependencies command") linux.run_command(command, check=True) + subprojects = self.config.get_os_subprojects() + if subprojects: + for subproject in subprojects: + deps = SubprojectDependencies(subproject) + deps.install() + + +class SubprojectDependencies: + + def __init__(self, subproject): + from lib.config import Config + + self.subproject = subproject + self.config = Config() + + def install(self): + """Installs dependencies for the current platform.""" + + print(f"Installing dependencies for sub-project: {self.subproject}") + + if env.is_linux(): + self.linux() + else: + raise RuntimeError(f"Unsupported platform: {os}") + + def linux(self): + """Installs dependencies on Linux.""" + import lib.linux as linux + + command = self.config.get_subproject_deps_command(self.subproject) + linux.run_command(command, check=True) + if __name__ == "__main__": main() diff --git a/scripts/lib/cmd_utils.py b/scripts/lib/cmd_utils.py index 1246d2468..bd85accfa 100644 --- a/scripts/lib/cmd_utils.py +++ b/scripts/lib/cmd_utils.py @@ -27,23 +27,26 @@ def has_command(command): return False -def strip_continuation_sequences(command): +def strip_continuation_sequences(command, strip_newlines=True): """ Remove the continuation sequences (\\) from a command. To spread strings over multiple lines in YAML files, like in bash, a backslash is used at the end of each line as continuation character. - When a YAML file is parsed, this becomes "\\ " (without a new line char), so this character - sequence must be removed before running the command. - This doesn't seem to be an issue on Windows, since the \\ path separator is rarely followed - by a space. """ - cmd_continuation = "\\ " if isinstance(command, list): - return [c.replace(cmd_continuation, "") for c in command] - else: - return command.replace(cmd_continuation, "") + raise ValueError("List commands are not supported") + + cmd_continuation = " \\" + command = command.replace(cmd_continuation, "") + + # Some versions of pyyaml will remove the newlines already, so always stripping + # makes the output more consistent. + if strip_newlines: + command = command.replace("\n", " ") + + return command def run( @@ -127,7 +130,7 @@ def run( # Take control of how failed commands are printed: # - if `print_cmd` is false, it will print `***` instead of the command # - if the command was a list, the command is printed as a readable string - raise RuntimeError(f"Command failed: {command_str}") from None + raise RuntimeError(f"Command failed: {command_str}") if result.returncode != 0: print( diff --git a/scripts/lib/config.py b/scripts/lib/config.py index dcb89c416..85cff7ee3 100644 --- a/scripts/lib/config.py +++ b/scripts/lib/config.py @@ -7,6 +7,7 @@ root_key = "config" deps_key = "dependencies" command_key = "command" command_pre_key = "command-pre" +subprojects_key = "subprojects" arrow = " ➤ " @@ -40,8 +41,8 @@ class Config: self.os_name = env.get_os() print("Config for OS:", self.os_name) - root = _get(data, root_key) - self.os = _get(root, self.os_name) + self.root = _get(data, root_key) + self.os = _get(self.root, self.os_name) def get_os_value(self, key, required=True, linux_distro=None): if linux_distro: @@ -78,6 +79,27 @@ class Config: else: return None + def get_os_subprojects(self): + distro, _distro_like, _distro_version = env.get_linux_distro() + return self.get_os_value(subprojects_key, linux_distro=distro, required=False) + + def get_subproject_deps_command(self, subproject_name): + subprojects = _get(self.root, subprojects_key) + subproject = _get(subprojects, subproject_name, subprojects_key) + deps_parent = f"{subprojects_key}{arrow}{subproject_name}" + deps = _get(subproject, deps_key, deps_parent) + + if env.is_linux(): + distro, _distro_like, _distro_version = env.get_linux_distro() + if not distro: + raise RuntimeError("Unable to detect Linux distro") + + command = _get(deps, distro, f"{deps_parent}{arrow}{deps_key}") + else: + command = _get(deps, self.os_name, f"{deps_parent}{arrow}{deps_key}") + + return cmd_utils.strip_continuation_sequences(command) + def get_os_deps_command_pre(self, required=True, linux_distro=None): return self.get_os_deps_command(command_pre_key, required, linux_distro) diff --git a/scripts/lib/linux.py b/scripts/lib/linux.py index 3364a9e7a..5b3a65320 100644 --- a/scripts/lib/linux.py +++ b/scripts/lib/linux.py @@ -1,4 +1,4 @@ -import os, shutil, glob +import os, shutil, glob, sys import lib.cmd_utils as cmd_utils import lib.env as env from enum import Enum, auto @@ -29,7 +29,7 @@ def run_command(command, check=True): cmd_utils.run(command, check, shell=True, print_cmd=True) -def package(filename_base, package_type: PackageType): +def package(filename_base, package_type: PackageType, leave_test_installed=False): extension, cmd = get_package_info(package_type) run_package_cmd(cmd) @@ -38,7 +38,7 @@ def package(filename_base, package_type: PackageType): target_path = copy_to_dist_dir(package_filename, target_file) if package_type == PackageType.DISTRO: - test_install(target_path) + test_install(target_path, remove_test=not leave_test_installed) def get_package_info(package_type: PackageType): @@ -113,26 +113,31 @@ def copy_to_dist_dir(source_file, target_file): return target_path -def test_install(package_file): +def test_install(package_file, remove_test=True): distro, distro_like, _distro_version = env.get_linux_distro() if not distro_like: distro_like = distro - install_pre = None - remove_pre = None + install_base = None + list_cmd = None + remove_base = None if "debian" in distro_like: - install_pre = ["apt", "install", "-f", "-y"] - remove_pre = ["apt", "remove", "-y"] + install_base = ["apt", "install", "-f", "-y"] + remove_base = ["apt", "remove", "-y"] + list_cmd = ["dpkg", "-L", "synergy"] elif "fedora" in distro_like: - install_pre = ["dnf", "install", "-y"] - remove_pre = ["dnf", "remove", "-y"] + install_base = ["dnf", "install", "-y"] + remove_base = ["dnf", "remove", "-y"] + list_cmd = ["rpm", "-ql", "synergy"] elif "opensuse" in distro_like: - install_pre = ["zypper", "--no-gpg-checks", "install", "-y"] - remove_pre = ["zypper", "remove", "-y"] + install_base = ["zypper", "--no-gpg-checks", "install", "-y"] + remove_base = ["zypper", "remove", "-y"] + list_cmd = ["rpm", "-ql", "synergy"] elif "arch" in distro_like: - install_pre = ["pacman", "-U", "--noconfirm"] - remove_pre = ["pacman", "-R", "--noconfirm"] + install_base = ["pacman", "-U", "--noconfirm"] + remove_base = ["pacman", "-R", "--noconfirm"] + list_cmd = ["pacman", "-Ql", "synergy"] else: raise RuntimeError(f"Linux distro not yet supported: {distro}") @@ -141,15 +146,46 @@ def test_install(package_file): print("Testing installation...") cmd_utils.run( - sudo + install_pre + [f"./{package_file}"], + sudo + install_base + [f"./{package_file}"], check=True, print_cmd=True, ) + print("Listing installed files...") + cmd_utils.run(sudo + list_cmd, check=True, print_cmd=True) + try: cmd_utils.run(test_cmd, shell=True, check=True, print_cmd=True) except Exception: raise RuntimeError("Unable to verify version") finally: - cmd_utils.run(sudo + remove_pre + [package_name], check=True, print_cmd=True) + if remove_test: + cmd_utils.run( + sudo + remove_base + [package_name], check=True, print_cmd=True + ) + else: + print("Leaving test package installed") + print("Installation test passed") + + +def is_package_available(package): + distro, distro_like, _distro_version = env.get_linux_distro() + if not distro_like: + distro_like = distro + + if "debian" in distro_like: + command = ["apt-cache", "show", package] + elif "fedora" in distro_like: + command = ["dnf", "info", package] + elif "opensuse" in distro_like: + command = ["zypper", "info", package] + elif "arch" in distro_like: + command = ["pacman", "-Si", package] + else: + raise RuntimeError(f"Linux distro not yet supported: {distro}") + + result = cmd_utils.run(command, check=False, print_cmd=True, get_output=True) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result.returncode == 0 diff --git a/scripts/lib/meson.py b/scripts/lib/meson.py new file mode 100644 index 000000000..8da6c2f12 --- /dev/null +++ b/scripts/lib/meson.py @@ -0,0 +1,25 @@ +import lib.cmd_utils as cmd_utils +import lib.env as env +import os + +build_dir = "build/meson" +meson_bin = env.get_python_executable("meson") + + +def setup(): + reconfigure = "--reconfigure" if os.path.exists(build_dir) else "" + cmd_utils.run([meson_bin, "setup", build_dir, reconfigure], print_cmd=True) + + +def compile(): + cmd_utils.run([meson_bin, "compile", "-C", build_dir], print_cmd=True) + + +def install(): + cmd = [meson_bin, "install", "-C", build_dir] + + has_sudo = cmd_utils.has_command("sudo") + if has_sudo: + cmd.insert(0, "sudo") + + cmd_utils.run(cmd, print_cmd=True) diff --git a/scripts/lint_cmake.py b/scripts/lint_cmake.py index 719a9b1d2..1efd51d79 100755 --- a/scripts/lint_cmake.py +++ b/scripts/lint_cmake.py @@ -13,7 +13,7 @@ include_files = [ "CMakeLists.txt", ] -exclude_dirs = ["ext", "build", "deps"] +exclude_dirs = ["subprojects", "build", "deps"] def main(): diff --git a/scripts/package.py b/scripts/package.py index 818e4a3cc..805c70a65 100755 --- a/scripts/package.py +++ b/scripts/package.py @@ -4,6 +4,7 @@ import lib.env as env env.ensure_in_venv(__file__) +import argparse import platform from lib.linux import PackageType from dotenv import load_dotenv # type: ignore @@ -13,6 +14,13 @@ default_package_prefix = "synergy" def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--leave-test-installed", + action="store_true", + help="Leave test package installed", + ) + args = parser.parse_args() load_dotenv(dotenv_path=env_file) @@ -25,7 +33,7 @@ def main(): elif env.is_mac(): mac_package(filename_base) elif env.is_linux(): - linux_package(filename_base, version) + linux_package(filename_base, version, args.leave_test_installed) else: raise RuntimeError(f"Unsupported platform: {env.get_os()}") @@ -68,12 +76,12 @@ def mac_package(filename_base): mac.package(filename_base) -def linux_package(filename_base, version): +def linux_package(filename_base, version, leave_test_installed): import lib.linux as linux extra_packages = env.get_env_bool("LINUX_EXTRA_PACKAGES", False) - linux.package(filename_base, PackageType.DISTRO) + linux.package(filename_base, PackageType.DISTRO, leave_test_installed) if extra_packages: filename_base = get_filename_base(version, use_linux_distro=False) diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index 092d65aad..e4840ec5a 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -11,4 +11,5 @@ dependencies = [ "dmgbuild; sys_platform == 'darwin'", "aqtinstall; sys_platform == 'win32' or sys_platform == 'darwin'", "colorama", + "meson", ] diff --git a/src/gui/src/AboutDialog.cpp b/src/gui/src/AboutDialog.cpp index 4ded04aae..735f3e4d7 100644 --- a/src/gui/src/AboutDialog.cpp +++ b/src/gui/src/AboutDialog.cpp @@ -66,39 +66,45 @@ void AboutDialog::updateLogo() const { QString AboutDialog::importantDevelopers() const { QStringList awesomePeople; + awesomePeople - // Chris is the ultimate creator, and the one who started it all in 2001. - awesomePeople << "Chris Schoeneman"; + // Chris is the ultimate creator, and the one who started it all in 2001. + << "Chris Schoeneman" - // Richard and Adam developed CosmoSynergy, the 90's predecessor project. - awesomePeople << "Richard Lee" << "Adam Feder"; + // Richard and Adam developed CosmoSynergy, the 90's predecessor project. + << "Richard Lee" + << "Adam Feder" - // Nick continued the legacy in 2009 started by Chris. - awesomePeople << "Nick Bolton"; + // Nick continued the legacy in 2009 started by Chris. + << "Nick Bolton" - // Volker wrote the first version of the GUI (QSynergy) in 2008. - awesomePeople << "Volker Lanz"; + // Volker wrote the first version of the GUI (QSynergy) in 2008. + << "Volker Lanz" - // Re-ignited the project in 2008 and rebuilt the community. - awesomePeople << "Sorin Sbârnea"; + // Re-ignited the project in 2008 and rebuilt the community. + << "Sorin Sbârnea" - // Contributors of bug fixes in the early days. - awesomePeople << "Ryan Breen" - << "Guido Poschta" - << "Bertrand Landry Hetu" - << "Tom Chadwick" - << "Brent Priddy" - << "Jason Axelson" - << "Jake Petroules"; + // Contributors of bug fixes in the early days. + << "Ryan Breen" + << "Guido Poschta" + << "Bertrand Landry Hetu" + << "Tom Chadwick" + << "Brent Priddy" + << "Jason Axelson" + << "Jake Petroules" - // Symless employees (in order of joining). - awesomePeople << "Kyle Bloom" - << "Daun Chung" - << "Serhii Hadzhylov" - << "Oleksandr Lysytsia" - << "Olena Kutytska" - << "Owen Phillips" - << "Daniel Evenson"; + // Implemented Wayland support (libei and libportal). + << "Peter Hutterer" + << "Olivier Fourdan" + + // Symless employees (in order of joining). + << "Kyle Bloom" + << "Daun Chung" + << "Serhii Hadzhylov" + << "Oleksandr Lysytsia" + << "Olena Kutytska" + << "Owen Phillips" + << "Daniel Evenson"; for (auto &person : awesomePeople) { // prevent names from breaking on the space when wrapped diff --git a/src/gui/src/SetupWizardBlocker.cpp b/src/gui/src/SetupWizardBlocker.cpp deleted file mode 100644 index 51e2acfee..000000000 --- a/src/gui/src/SetupWizardBlocker.cpp +++ /dev/null @@ -1,58 +0,0 @@ -/* - * synergy -- mouse and keyboard sharing utility - * Copyright (C) 2021 Symless Ltd. - * - * This package is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * found in the file LICENSE that should have accompanied this file. - * - * This package is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#include "SetupWizardBlocker.h" - -#include "MainWindow.h" - -#include -#include - -static const std::vector blockerTitels = { - "Wayland is not yet supported", -}; - -static const std::vector blockerText = { - "We have detected your system is using Wayland which is not currently \n" - "supported, but we are working on it. It's top of our priority list. \n" - "\n" - "Please switch to Xorg if you wish to continue using Synergy today.", -}; - -SetupWizardBlocker::SetupWizardBlocker(BlockerType type) { - setupUi(this); - - m_pLabelTitle->setText(blockerTitels[static_cast(type)]); - m_pLabelInfo->setText(blockerText[static_cast(type)]); - - connect( - m_pButtonSupport, &QPushButton::released, this, - &SetupWizardBlocker::onlineSupport); - connect( - m_pButtonCancel, &QPushButton::released, this, - &SetupWizardBlocker::cancel); -} - -void SetupWizardBlocker::onlineSupport() { - QDesktopServices::openUrl(QUrl(synergy::gui::kUrlHelp)); - cancel(); -} - -void SetupWizardBlocker::cancel() { - QDialog::reject(); - QCoreApplication::quit(); -} diff --git a/src/gui/src/SetupWizardBlocker.h b/src/gui/src/SetupWizardBlocker.h deleted file mode 100644 index 36be88be4..000000000 --- a/src/gui/src/SetupWizardBlocker.h +++ /dev/null @@ -1,36 +0,0 @@ -/* - * synergy -- mouse and keyboard sharing utility - * Copyright (C) 2021 Symless Ltd. - * - * This package is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * found in the file LICENSE that should have accompanied this file. - * - * This package is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -#pragma once - -#include "ui_SetupWizardBlocker.h" - -#include - -class MainWindow; - -class SetupWizardBlocker : public QDialog, public Ui::SetupWizardBlocker { - Q_OBJECT - -public: - enum class BlockerType { Wayland }; - explicit SetupWizardBlocker(BlockerType type); - -protected: - void onlineSupport(); - void cancel(); -}; diff --git a/src/gui/src/SetupWizardBlocker.ui b/src/gui/src/SetupWizardBlocker.ui deleted file mode 100644 index c9d59f8e7..000000000 --- a/src/gui/src/SetupWizardBlocker.ui +++ /dev/null @@ -1,129 +0,0 @@ - - - SetupWizardBlocker - - - - 0 - 0 - 750 - 600 - - - - Qt::NoContextMenu - - - Setup Synergy - - - 1.000000000000000 - - - - 20 - - - 40 - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - 0 - 0 - - - - - - - :/res/image/setupBlocker.png - - - true - - - 30 - - - - - - - - 18 - true - - - - m_pLabelTitle - - - - - - - m_pLabelInfo - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - 0 - 0 - - - - Get technical support - - - - - - - - 0 - 0 - - - - Exit - - - - - - - - - - diff --git a/src/gui/src/main.cpp b/src/gui/src/main.cpp index 4bc038f7b..13f0d6b7e 100644 --- a/src/gui/src/main.cpp +++ b/src/gui/src/main.cpp @@ -19,7 +19,6 @@ #include "MainWindow.h" #include "QSynergyApplication.h" #include "SetupWizard.h" -#include "SetupWizardBlocker.h" #include "common/constants.h" #include "common/version.h" #include "gui/Logger.h" @@ -117,14 +116,6 @@ int main(int argc, char *argv[]) { &configScopes, &ConfigScopes::saving, &appConfig, [&appConfig]() { appConfig.commit(); }, Qt::DirectConnection); - std::unique_ptr setupBlocker; - if (qgetenv("XDG_SESSION_TYPE") == "wayland") { - SetupWizardBlocker blocked(SetupWizardBlocker::BlockerType::Wayland); - blocked.exec(); - qInfo("wayland detected, exiting"); - return 0; - } - if (appConfig.wizardShouldRun()) { SetupWizard wizard(appConfig); auto result = wizard.exec(); diff --git a/src/lib/base/EventQueue.cpp b/src/lib/base/EventQueue.cpp index da2d12a82..d494eabc7 100644 --- a/src/lib/base/EventQueue.cpp +++ b/src/lib/base/EventQueue.cpp @@ -48,6 +48,7 @@ EVENT_TYPE_ACCESSOR(IPrimaryScreen) EVENT_TYPE_ACCESSOR(IScreen) EVENT_TYPE_ACCESSOR(Clipboard) EVENT_TYPE_ACCESSOR(File) +EVENT_TYPE_ACCESSOR(Ei) // interrupt handler. this just adds a quit event to the queue. static void interrupt(Arch::ESignal, void *data) { @@ -82,6 +83,7 @@ EventQueue::EventQueue() m_typesForIScreen(NULL), m_typesForClipboard(NULL), m_typesForFile(NULL), + m_typesForEi(NULL), m_readyMutex(new Mutex), m_readyCondVar(new CondVar(m_readyMutex, false)) { m_mutex = ARCH->newMutex(); diff --git a/src/lib/base/EventQueue.h b/src/lib/base/EventQueue.h index e9cc30bbe..a0c42b27e 100644 --- a/src/lib/base/EventQueue.h +++ b/src/lib/base/EventQueue.h @@ -20,6 +20,7 @@ #include "arch/IArchMultithread.h" #include "base/Event.h" +#include "base/EventTypes.h" #include "base/IEventQueue.h" #include "base/PriorityQueue.h" #include "base/Stopwatch.h" @@ -158,6 +159,7 @@ public: IScreenEvents &forIScreen(); ClipboardEvents &forClipboard(); FileEvents &forFile(); + EiEvents &forEi(); private: ClientEvents *m_typesForClient; @@ -180,6 +182,7 @@ private: IScreenEvents *m_typesForIScreen; ClipboardEvents *m_typesForClipboard; FileEvents *m_typesForFile; + EiEvents *m_typesForEi; Mutex *m_readyMutex; CondVar *m_readyCondVar; std::queue m_pending; diff --git a/src/lib/base/EventTypes.cpp b/src/lib/base/EventTypes.cpp index 34e8ccba6..be39be562 100644 --- a/src/lib/base/EventTypes.cpp +++ b/src/lib/base/EventTypes.cpp @@ -193,3 +193,10 @@ REGISTER_EVENT(Clipboard, clipboardSending) REGISTER_EVENT(File, fileChunkSending) REGISTER_EVENT(File, fileRecieveCompleted) REGISTER_EVENT(File, keepAlive) + +// +// Ei +// + +REGISTER_EVENT(Ei, connected) +REGISTER_EVENT(Ei, sessionClosed) diff --git a/src/lib/base/EventTypes.h b/src/lib/base/EventTypes.h index 71687658d..932a909e5 100644 --- a/src/lib/base/EventTypes.h +++ b/src/lib/base/EventTypes.h @@ -750,3 +750,31 @@ private: Event::Type m_fileRecieveCompleted; Event::Type m_keepAlive; }; + +class EiEvents : public EventTypes { +public: + EiEvents() : m_connected(Event::kUnknown), m_sessionClosed(Event::kUnknown) {} + + //! @name accessors + //@{ + + //! Get connected event type + /*! + This event is sent whenever connection to EIS is established and a file + descriptor for reading events is available. + */ + Event::Type connected(); + + //! Get session closed event type + /*! + This event is sent whenever the portal session managing our EIS connection + is closed. + */ + Event::Type sessionClosed(); + + //@} + +private: + Event::Type m_connected; + Event::Type m_sessionClosed; +}; diff --git a/src/lib/base/IEventQueue.h b/src/lib/base/IEventQueue.h index 787da00ff..1646ceff3 100644 --- a/src/lib/base/IEventQueue.h +++ b/src/lib/base/IEventQueue.h @@ -50,6 +50,7 @@ class IPrimaryScreenEvents; class IScreenEvents; class ClipboardEvents; class FileEvents; +class EiEvents; //! Event queue interface /*! @@ -243,4 +244,5 @@ public: virtual IScreenEvents &forIScreen() = 0; virtual ClipboardEvents &forClipboard() = 0; virtual FileEvents &forFile() = 0; + virtual EiEvents &forEi() = 0; }; diff --git a/src/lib/base/Log.h b/src/lib/base/Log.h index 07a4c1090..732188e9d 100644 --- a/src/lib/base/Log.h +++ b/src/lib/base/Log.h @@ -216,3 +216,16 @@ otherwise it expands to a call that doesn't. #define CLOG_DEBUG3 CLOG_TRACE "%z\070" #define CLOG_DEBUG4 CLOG_TRACE "%z\071" // char is '9' #define CLOG_DEBUG5 CLOG_TRACE "%z\072" // char is ':' + +#define LOG_PRINT(...) LOG((CLOG_PRINT __VA_ARGS__)) +#define LOG_CRIT(...) LOG((CLOG_CRIT __VA_ARGS__)) +#define LOG_ERR(...) LOG((CLOG_ERR __VA_ARGS__)) +#define LOG_WARN(...) LOG((CLOG_WARN __VA_ARGS__)) +#define LOG_NOTE(...) LOG((CLOG_NOTE __VA_ARGS__)) +#define LOG_INFO(...) LOG((CLOG_INFO __VA_ARGS__)) +#define LOG_DEBUG(...) LOG((CLOG_DEBUG __VA_ARGS__)) +#define LOG_DEBUG1(...) LOG((CLOG_DEBUG1 __VA_ARGS__)) +#define LOG_DEBUG2(...) LOG((CLOG_DEBUG2 __VA_ARGS__)) +#define LOG_DEBUG3(...) LOG((CLOG_DEBUG3 __VA_ARGS__)) +#define LOG_DEBUG4(...) LOG((CLOG_DEBUG4 __VA_ARGS__)) +#define LOG_DEBUG5(...) LOG((CLOG_DEBUG5 __VA_ARGS__)) diff --git a/src/lib/gui/TrayIcon.cpp b/src/lib/gui/TrayIcon.cpp index fbbb26374..98df215ff 100644 --- a/src/lib/gui/TrayIcon.cpp +++ b/src/lib/gui/TrayIcon.cpp @@ -17,6 +17,7 @@ #include "TrayIcon.h" +#include "Logger.h" #include "common/constants.h" namespace synergy::gui { @@ -43,7 +44,8 @@ void TrayIcon::showRetryLoop() { } else { // on some platforms, it's not always possible to create the tray when the // app starts, so keep trying until it is possible. - qDebug("system tray not ready yet, retrying in %d ms", kShowRetryInterval); + logVerbose(QString("system tray not ready yet, retrying in %1 ms") + .arg(kShowRetryInterval)); QTimer::singleShot(kShowRetryInterval, this, &TrayIcon::showRetryLoop); } } diff --git a/src/lib/platform/CMakeLists.txt b/src/lib/platform/CMakeLists.txt index a8b23812b..9be8318d0 100644 --- a/src/lib/platform/CMakeLists.txt +++ b/src/lib/platform/CMakeLists.txt @@ -27,8 +27,32 @@ elseif(APPLE) "OSX*.m" "OSX*.mm") elseif(UNIX) - file(GLOB headers "XWindows*.h") - file(GLOB sources "XWindows*.cpp") + file(GLOB x_headers "XWindows*.h") + file(GLOB x_sources "XWindows*.cpp") + + if(LIBEI_FOUND) + file(GLOB ei_headers "Ei*.h") + file(GLOB ei_sources "Ei*.cpp") + + # The Portal sources also require EI. + if(LIBPORTAL_FOUND) + file(GLOB portal_headers "Portal*.h") + file(GLOB portal_sources "Portal*.cpp") + endif() + endif() + + list( + APPEND + headers + ${x_headers} + ${ei_headers} + ${portal_headers}) + list( + APPEND + sources + ${x_sources} + ${ei_sources} + ${portal_sources}) endif() if(ADD_HEADERS_TO_SOURCES) @@ -43,6 +67,19 @@ include_directories(${inc}) add_library(platform STATIC ${sources}) target_link_libraries(platform client ${libs}) +macro(link_wayland_libs) + target_link_libraries(platform ${LIBXKBCOMMON_LINK_LIBRARIES} + ${GLIB2_LINK_LIBRARIES} ${LIBM_LIBRARIES}) + + if(LIBEI_FOUND) + target_link_libraries(platform ${LIBEI_LINK_LIBRARIES}) + endif() + + if(LIBPORTAL_FOUND) + target_link_libraries(platform ${LIBPORTAL_LINK_LIBRARIES}) + endif() +endmacro() + if(UNIX) target_link_libraries( platform @@ -56,6 +93,8 @@ if(UNIX) if(NOT APPLE) find_package(Qt6 COMPONENTS DBus) target_link_libraries(platform Qt6::DBus) + + link_wayland_libs() endif() endif() diff --git a/src/lib/platform/EiEventQueueBuffer.cpp b/src/lib/platform/EiEventQueueBuffer.cpp new file mode 100644 index 000000000..819aeba47 --- /dev/null +++ b/src/lib/platform/EiEventQueueBuffer.cpp @@ -0,0 +1,162 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "platform/EiEventQueueBuffer.h" + +#include "base/Event.h" +#include "base/EventTypes.h" +#include "base/IEventQueue.h" +#include "base/Log.h" +#include "mt/Thread.h" + +#include +#include +#include +#include +#include + +class EventQueueTimer {}; + +namespace synergy { + +EiEventQueueBuffer::EiEventQueueBuffer( + EiScreen *screen, ei *ei, IEventQueue *events) + : ei_(ei_ref(ei)), + events_(events) { + // We need a pipe to signal ourselves when addEvent() is called + int pipefd[2]; + int result = pipe(pipefd); + assert(result == 0); + + int pipeflags; + pipeflags = fcntl(pipefd[0], F_GETFL); + fcntl(pipefd[0], F_SETFL, pipeflags | O_NONBLOCK); + pipeflags = fcntl(pipefd[1], F_GETFL); + fcntl(pipefd[1], F_SETFL, pipeflags | O_NONBLOCK); + + pipe_r_ = pipefd[0]; + pipe_w_ = pipefd[1]; +} + +EiEventQueueBuffer::~EiEventQueueBuffer() { + ei_unref(ei_); + close(pipe_r_); + close(pipe_w_); +} + +void EiEventQueueBuffer::waitForEvent(double timeout_in_ms) { + Thread::testCancel(); + + enum { + EIFD, + PIPEFD, + POLLFD_COUNT, // Last element + }; + + struct pollfd pfds[POLLFD_COUNT]; + pfds[EIFD].fd = ei_get_fd(ei_); + pfds[EIFD].events = POLLIN; + pfds[PIPEFD].fd = pipe_r_; + pfds[PIPEFD].events = POLLIN; + + int timeout = + (timeout_in_ms < 0.0) ? -1 : static_cast(1000.0 * timeout_in_ms); + + int retval = poll(pfds, POLLFD_COUNT, timeout); + if (retval > 0) { + if (pfds[EIFD].revents & POLLIN) { + std::lock_guard lock(mutex_); + + // libei doesn't allow ei_event_ref() because events are + // supposed to be short-lived only. So instead, we create an NULL-data + // kSystemEvent whenever there's data on the fd, shove that event + // into our event queue and once we process the event (see + // getEvent()), the EiScreen will call ei_dispatch() and process + // all actual pending ei events. In theory this means that a + // flood of ei events could starve the events added with + // addEvents() but let's hope it doesn't come to that. + queue_.push({true, 0U}); + } + // the pipefd data doesn't matter, it only exists to wake up the thread + // and potentially testCancel + if (pfds[PIPEFD].revents & POLLIN) { + char buf[64]; + auto result = read(pipe_r_, buf, sizeof(buf)); // discard + LOG_DEBUG("event queue read result: %d", result); + } + } + Thread::testCancel(); +} + +IEventQueueBuffer::Type +EiEventQueueBuffer::getEvent(Event &event, uint32_t &dataID) { + // the addEvent/getEvent pair is a bit awkward for libei. + // + // it assumes that there's a nice queue of events sitting there that we can + // just append to and get everything back out in the same order. We *could* + // emulate that by taking the libei events immediately out of the event + // queue after dispatch (see above) and putting it into the event queue, + // intermixed with whatever addEvents() did. + // + // But this makes locking more awkward and libei isn't really designed to + // keep calling ei_dispatch() while we hold a bunch of event refs. So instead + // we just have a "something happened" event on the ei fd and the rest is + // handled by the EiScreen. + // + std::lock_guard lock(mutex_); + auto pair = queue_.front(); + queue_.pop(); + + // if this an injected special event, just return the data and exit + if (pair.first == false) { + dataID = pair.second; + return kUser; + } + + event = Event(Event::kSystem, events_->getSystemTarget()); + + return kSystem; +} + +bool EiEventQueueBuffer::addEvent(uint32_t dataID) { + std::lock_guard lock(mutex_); + queue_.push({false, dataID}); + + // tickle the pipe so our read thread wakes up + auto result = write(pipe_w_, "!", 1); + LOG_DEBUG("event queue write result: %d", result); + + return true; +} + +bool EiEventQueueBuffer::isEmpty() const { + std::lock_guard lock(mutex_); + + return queue_.empty(); +} + +EventQueueTimer * +EiEventQueueBuffer::newTimer(double duration, bool oneShot) const { + return new EventQueueTimer; +} + +void EiEventQueueBuffer::deleteTimer(EventQueueTimer *timer) const { + delete timer; +} + +} // namespace synergy diff --git a/src/lib/platform/EiEventQueueBuffer.h b/src/lib/platform/EiEventQueueBuffer.h new file mode 100644 index 000000000..af4e35024 --- /dev/null +++ b/src/lib/platform/EiEventQueueBuffer.h @@ -0,0 +1,59 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "config.h" + +#include "base/IEventQueueBuffer.h" +#include "mt/Thread.h" +#include "platform/EiScreen.h" +#include "synergy/IScreen.h" + +#include +#include +#include +#include + +namespace synergy { + +//! Event queue buffer for Ei +class EiEventQueueBuffer : public IEventQueueBuffer { +public: + EiEventQueueBuffer(EiScreen *screen, ei *ei, IEventQueue *events); + ~EiEventQueueBuffer(); + + // IEventQueueBuffer overrides + void init() override {} + void waitForEvent(double timeout_in_ms) override; + Type getEvent(Event &event, uint32_t &dataID) override; + bool addEvent(uint32_t dataID) override; + bool isEmpty() const override; + EventQueueTimer *newTimer(double duration, bool oneShot) const override; + void deleteTimer(EventQueueTimer *) const override; + +private: + ei *ei_; + IEventQueue *events_; + std::queue> queue_; + int pipe_w_, pipe_r_; + + mutable std::mutex mutex_; +}; + +} // namespace synergy diff --git a/src/lib/platform/EiKeyState.cpp b/src/lib/platform/EiKeyState.cpp new file mode 100644 index 000000000..16013271a --- /dev/null +++ b/src/lib/platform/EiKeyState.cpp @@ -0,0 +1,282 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "platform/EiKeyState.h" + +#include "base/Log.h" +#include "platform/XWindowsUtil.h" +#include "synergy/AppUtil.h" +#include "synergy/ClientApp.h" + +#include +#include +#include +#include +#include + +namespace synergy { + +EiKeyState::EiKeyState(EiScreen *screen, IEventQueue *events) + : KeyState( + events, AppUtil::instance().getKeyboardLayoutList(), + ClientApp::instance().args().m_enableLangSync), + screen_{screen} { + xkb_ = xkb_context_new(XKB_CONTEXT_NO_FLAGS); + + // FIXME: PrimaryClient->enable() calls into our keymap, so we must have + // one during initial startup - even before we know what our actual keymap is. + // Once we get the actual keymap from EIS, we swap it out so hopefully that's + // enough. + init_default_keymap(); +} + +void EiKeyState::init_default_keymap() { + if (xkb_keymap_) { + xkb_keymap_unref(xkb_keymap_); + } + xkb_keymap_ = + xkb_keymap_new_from_names(xkb_, nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS); + + if (xkb_state_) { + xkb_state_unref(xkb_state_); + } + xkb_state_ = xkb_state_new(xkb_keymap_); +} + +void EiKeyState::init(int fd, size_t len) { + auto buffer = std::make_unique(len + 1); + auto sz = read(fd, buffer.get(), len); + + if ((size_t)sz < len) { + LOG_NOTE("failed to create xkb context: %s", strerror(errno)); + return; + } + + // See xkbcommon/libxkbcommon issue #307, xkb_keymap_new_from_buffer fails if + // we have a terminating null byte. Since we can't control whether the other + // end sends that byte, enforce null-termination in our buffer and pass the + // whole thing as string. + + buffer[len] = '\0'; // guarantee null-termination + auto keymap = xkb_keymap_new_from_string( + xkb_, buffer.get(), XKB_KEYMAP_FORMAT_TEXT_V1, + XKB_KEYMAP_COMPILE_NO_FLAGS); + if (!keymap) { + LOG_NOTE("failed to compile keymap, falling back to defaults"); + // Falling back to layout "us" is a lot more useful than segfaulting + init_default_keymap(); + return; + } + + if (xkb_keymap_) { + xkb_keymap_unref(xkb_keymap_); + } + xkb_keymap_ = keymap; + + if (xkb_state_) { + xkb_state_unref(xkb_state_); + } + xkb_state_ = xkb_state_new(xkb_keymap_); +} + +EiKeyState::~EiKeyState() { + xkb_context_unref(xkb_); + xkb_keymap_unref(xkb_keymap_); + xkb_state_unref(xkb_state_); +} + +bool EiKeyState::fakeCtrlAltDel() { + // pass keys through unchanged + return false; +} + +KeyModifierMask EiKeyState::pollActiveModifiers() const { + std::uint32_t xkb_mask = + xkb_state_serialize_mods(xkb_state_, XKB_STATE_MODS_EFFECTIVE); + return convert_mod_mask(xkb_mask); +} + +std::int32_t EiKeyState::pollActiveGroup() const { + return xkb_state_serialize_layout(xkb_state_, XKB_STATE_LAYOUT_EFFECTIVE); +} + +void EiKeyState::pollPressedKeys(KeyButtonSet &pressedKeys) const { + // FIXME + return; +} + +std::uint32_t EiKeyState::convert_mod_mask(std::uint32_t xkb_mask) const { + std::uint32_t barrier_mask = 0; + + for (xkb_mod_index_t xkbmod = 0; xkbmod < xkb_keymap_num_mods(xkb_keymap_); + xkbmod++) { + if ((xkb_mask & (1 << xkbmod)) == 0) + continue; + + const char *name = xkb_keymap_mod_get_name(xkb_keymap_, xkbmod); + if (strcmp(XKB_MOD_NAME_SHIFT, name) == 0) + barrier_mask |= (1 << kKeyModifierBitShift); + else if (strcmp(XKB_MOD_NAME_CAPS, name) == 0) + barrier_mask |= (1 << kKeyModifierBitCapsLock); + else if (strcmp(XKB_MOD_NAME_CTRL, name) == 0) + barrier_mask |= (1 << kKeyModifierBitControl); + else if (strcmp(XKB_MOD_NAME_ALT, name) == 0) + barrier_mask |= (1 << kKeyModifierBitAlt); + else if (strcmp(XKB_MOD_NAME_LOGO, name) == 0) + barrier_mask |= (1 << kKeyModifierBitSuper); + } + + return barrier_mask; +} + +// Only way to figure out whether a key is a modifier key is to press it, +// check if a modifier changed state and then release it again. +// Luckily xkbcommon allows us to do this in a separate state. +void EiKeyState::assign_generated_modifiers( + std::uint32_t keycode, synergy::KeyMap::KeyItem &item) { + std::uint32_t mods_generates = 0; + auto state = xkb_state_new(xkb_keymap_); + enum xkb_state_component changed = + xkb_state_update_key(state, keycode, XKB_KEY_DOWN); + + if (changed) { + for (xkb_mod_index_t m = 0; m < xkb_keymap_num_mods(xkb_keymap_); m++) { + if (xkb_state_mod_index_is_active(state, m, XKB_STATE_MODS_LOCKED)) + item.m_lock = true; + + if (xkb_state_mod_index_is_active(state, m, XKB_STATE_MODS_EFFECTIVE)) { + mods_generates |= (1 << m); + } + } + } + xkb_state_update_key(state, keycode, XKB_KEY_UP); + xkb_state_unref(state); + + item.m_generates = convert_mod_mask(mods_generates); +} + +void EiKeyState::getKeyMap(synergy::KeyMap &keyMap) { + auto min_keycode = xkb_keymap_min_keycode(xkb_keymap_); + auto max_keycode = xkb_keymap_max_keycode(xkb_keymap_); + + // X keycodes are evdev keycodes + 8 (libei gives us evdev keycodes) + for (auto keycode = min_keycode; keycode <= max_keycode; keycode++) { + + // skip keys with no groups (they generate no symbols) + if (xkb_keymap_num_layouts_for_key(xkb_keymap_, keycode) == 0) + continue; + + for (auto group = 0U; group < xkb_keymap_num_layouts(xkb_keymap_); + group++) { + for (auto level = 0U; + level < xkb_keymap_num_levels_for_key(xkb_keymap_, keycode, group); + level++) { + const xkb_keysym_t *syms; + xkb_mod_mask_t masks[64]; + auto nmasks = xkb_keymap_key_get_mods_for_level( + xkb_keymap_, keycode, group, level, masks, 64); + auto nsyms = xkb_keymap_key_get_syms_by_level( + xkb_keymap_, keycode, group, level, &syms); + + if (nsyms == 0) + continue; + + if (nsyms > 1) + LOG_WARN( + "multiple keysyms per keycode are not supported, keycode %d", + keycode); + + synergy::KeyMap::KeyItem item{}; + xkb_keysym_t keysym = syms[0]; + KeySym sym = static_cast(keysym); + item.m_id = XWindowsUtil::mapKeySymToKeyID(sym); + item.m_button = static_cast(keycode) - 8; // X keycode offset + item.m_group = group; + + // For debugging only + char keysym_name[128] = {0}; + xkb_keysym_get_name(keysym, keysym_name, sizeof(keysym_name)); + + // Set to all modifiers this key may be affected by + uint32_t mods_sensitive = 0; + for (auto n = 0U; n < nmasks; n++) { + mods_sensitive |= masks[n]; + } + item.m_sensitive = convert_mod_mask(mods_sensitive); + + uint32_t mods_required = 0; + for (std::size_t m = 0; m < nmasks; m++) { + mods_required |= masks[m]; + } + item.m_required = convert_mod_mask(mods_required); + + assign_generated_modifiers(keycode, item); + + // add capslock version of key is sensitive to capslock + if (item.m_sensitive & KeyModifierShift && + item.m_sensitive & KeyModifierCapsLock) { + item.m_required &= ~KeyModifierShift; + item.m_required |= KeyModifierCapsLock; + keyMap.addKeyEntry(item); + item.m_required |= KeyModifierShift; + item.m_required &= ~KeyModifierCapsLock; + } + + keyMap.addKeyEntry(item); + } + } + } + + // allow composition across groups + keyMap.allowGroupSwitchDuringCompose(); +} + +void EiKeyState::fakeKey(const Keystroke &keystroke) { + switch (keystroke.m_type) { + case Keystroke::kButton: + LOG_DEBUG1( + "fake key: %03x (%08x) %s", keystroke.m_data.m_button.m_button, + keystroke.m_data.m_button.m_client, + keystroke.m_data.m_button.m_press ? "down" : "up"); + screen_->fakeKey( + keystroke.m_data.m_button.m_button, keystroke.m_data.m_button.m_press); + break; + default: + break; + } +} + +KeyID EiKeyState::map_key_from_keyval(uint32_t keyval) const { + // FIXME: That might be a bit crude...? + xkb_keysym_t xkb_keysym = xkb_state_key_get_one_sym(xkb_state_, keyval); + KeySym keysym = static_cast(xkb_keysym); + + KeyID keyid = XWindowsUtil::mapKeySymToKeyID(keysym); + LOG_DEBUG1( + "mapped key: code=%d keysym=0x%04lx to keyID=%d", keyval, keysym, keyid); + + return keyid; +} + +void EiKeyState::update_xkb_state(uint32_t keyval, bool is_pressed) { + LOG_DEBUG1("update key state: keyval=%d pressed=%i", keyval, is_pressed); + xkb_state_update_key( + xkb_state_, keyval, is_pressed ? XKB_KEY_DOWN : XKB_KEY_UP); +} + +} // namespace synergy diff --git a/src/lib/platform/EiKeyState.h b/src/lib/platform/EiKeyState.h new file mode 100644 index 000000000..9cd651cf4 --- /dev/null +++ b/src/lib/platform/EiKeyState.h @@ -0,0 +1,63 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "platform/EiScreen.h" +#include "synergy/KeyState.h" + +struct xkb_context; +struct xkb_keymap; +struct xkb_state; + +namespace synergy { + +/// A key state for Ei +class EiKeyState : public KeyState { +public: + EiKeyState(EiScreen *screen, IEventQueue *events); + ~EiKeyState(); + + void init(int fd, std::size_t len); + void init_default_keymap(); + + // IKeyState overrides + bool fakeCtrlAltDel() override; + KeyModifierMask pollActiveModifiers() const override; + std::int32_t pollActiveGroup() const override; + void pollPressedKeys(KeyButtonSet &pressedKeys) const override; + KeyID map_key_from_keyval(std::uint32_t keyval) const; + void update_xkb_state(std::uint32_t keyval, bool is_pressed); + +protected: + // KeyState overrides + void getKeyMap(KeyMap &keyMap) override; + void fakeKey(const Keystroke &keystroke) override; + +private: + std::uint32_t convert_mod_mask(std::uint32_t xkb_mask) const; + void assign_generated_modifiers(std::uint32_t keycode, KeyMap::KeyItem &item); + + EiScreen *screen_ = nullptr; + + xkb_context *xkb_ = nullptr; + xkb_keymap *xkb_keymap_ = nullptr; + xkb_state *xkb_state_ = nullptr; +}; + +} // namespace synergy diff --git a/src/lib/platform/EiScreen.cpp b/src/lib/platform/EiScreen.cpp new file mode 100644 index 000000000..df58ec27d --- /dev/null +++ b/src/lib/platform/EiScreen.cpp @@ -0,0 +1,852 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "platform/EiScreen.h" + +#include "arch/Arch.h" +#include "arch/XArch.h" +#include "base/IEventQueue.h" +#include "base/Log.h" +#include "base/Stopwatch.h" +#include "base/TMethodEventJob.h" +#include "platform/EiEventQueueBuffer.h" +#include "platform/EiKeyState.h" +#include "synergy/Clipboard.h" +#include "synergy/KeyMap.h" +#include "synergy/XScreen.h" + +#if WINAPI_LIBPORTAL +#include "platform/PortalInputCapture.h" +#include "platform/PortalRemoteDesktop.h" +#endif + +#include +#include +#include +#include +#include +#include + +struct ScrollRemainder { + double x, y; // scroll remainder in pixels +}; + +namespace synergy { + +EiScreen::EiScreen(bool is_primary, IEventQueue *events, bool use_portal) + : PlatformScreen(events), + is_primary_(is_primary), + events_(events), + w_(1), + h_(1), + is_on_screen_(is_primary) { + init_ei(); + key_state_ = new EiKeyState(this, events); + // install event handlers + events_->adoptHandler( + Event::kSystem, events_->getSystemTarget(), + new TMethodEventJob(this, &EiScreen::handleSystemEvent)); + + if (use_portal) { + events_->adoptHandler( + events_->forEi().connected(), getEventTarget(), + new TMethodEventJob( + this, &EiScreen::handle_connected_to_eis_event)); + if (is_primary) { +#if HAVE_LIBPORTAL_INPUTCAPTURE + portal_input_capture_ = new PortalInputCapture(this, events_); +#else + throw std::invalid_argument( + "Missing libportal InputCapture portal support"); +#endif // HAVE_LIBPORTAL_INPUTCAPTURE + } else { +#if WINAPI_LIBPORTAL + events_->adoptHandler( + events_->forEi().sessionClosed(), getEventTarget(), + new TMethodEventJob( + this, &EiScreen::handle_portal_session_closed)); + portal_remote_desktop_ = new PortalRemoteDesktop(this, events_); +#else + throw std::invalid_argument( + "Missing libportal RemoteDesktop portal support"); +#endif // WINAPI_LIBPORTAL + } + } else { + // Note: socket backend does not support reconnections + auto rc = ei_setup_backend_socket(ei_, nullptr); + if (rc != 0) { + LOG_DEBUG("ei init error: %s", strerror(-rc)); + throw std::runtime_error("Failed to init ei context"); + } + } +} + +EiScreen::~EiScreen() { + events_->adoptBuffer(nullptr); + events_->removeHandler(Event::kSystem, events_->getSystemTarget()); + + cleanup_ei(); + + delete key_state_; + +#if WINAPI_LIBPORTAL + delete portal_remote_desktop_; +#endif +#if HAVE_LIBPORTAL_INPUTCAPTURE + delete portal_input_capture_; +#endif +} + +void EiScreen::handle_ei_log_event( + ei *ei, ei_log_priority priority, const char *message, + ei_log_context *context) { + switch (priority) { + case EI_LOG_PRIORITY_DEBUG: + LOG_DEBUG("ei: %s", message); + break; + case EI_LOG_PRIORITY_INFO: + LOG_INFO("ei: %s", message); + break; + case EI_LOG_PRIORITY_WARNING: + LOG_WARN("ei: %s", message); + break; + case EI_LOG_PRIORITY_ERROR: + LOG_ERR("ei: %s", message); + break; + default: + LOG_PRINT("ei: %s", message); + break; + } +} + +void EiScreen::init_ei() { + if (is_primary_) { + ei_ = ei_new_receiver(nullptr); // we receive from the display server + } else { + ei_ = ei_new_sender(nullptr); // we send to the display server + } + ei_set_user_data(ei_, this); + ei_log_set_priority(ei_, EI_LOG_PRIORITY_DEBUG); + ei_log_set_handler(ei_, cb_handle_ei_log_event); + ei_configure_name(ei_, "synergy client"); + + // install the platform event queue + events_->adoptBuffer(nullptr); + events_->adoptBuffer(new EiEventQueueBuffer(this, ei_, events_)); +} + +void EiScreen::cleanup_ei() { + if (ei_pointer_) { + free(ei_device_get_user_data(ei_pointer_)); + ei_device_set_user_data(ei_pointer_, nullptr); + ei_pointer_ = ei_device_unref(ei_pointer_); + } + if (ei_keyboard_) { + free(ei_device_get_user_data(ei_keyboard_)); + ei_device_set_user_data(ei_keyboard_, nullptr); + ei_keyboard_ = ei_device_unref(ei_keyboard_); + } + if (ei_abs_) { + free(ei_device_get_user_data(ei_abs_)); + ei_device_set_user_data(ei_abs_, nullptr); + ei_abs_ = ei_device_unref(ei_abs_); + } + ei_seat_unref(ei_seat_); + for (auto it = ei_devices_.begin(); it != ei_devices_.end(); it++) { + free(ei_device_get_user_data(*it)); + ei_device_set_user_data(*it, nullptr); + ei_device_unref(*it); + } + ei_devices_.clear(); + ei_ = ei_unref(ei_); +} + +void *EiScreen::getEventTarget() const { + return const_cast(static_cast(this)); +} + +bool EiScreen::getClipboard(ClipboardID id, IClipboard *clipboard) const { + return false; +} + +void EiScreen::getShape(int32_t &x, int32_t &y, int32_t &w, int32_t &h) const { + x = x_; + y = y_; + w = w_; + h = h_; +} + +void EiScreen::getCursorPos(int32_t &x, int32_t &y) const { + x = cursor_x_; + y = cursor_y_; +} + +void EiScreen::reconfigure(uint32_t) { + // do nothing +} + +void EiScreen::warpCursor(int32_t x, int32_t y) { + cursor_x_ = x; + cursor_y_ = y; +} + +std::uint32_t EiScreen::registerHotKey(KeyID key, KeyModifierMask mask) { + static std::uint32_t next_id; + std::uint32_t id = std::min(++next_id, 1u); + + // Bug: id rollover means duplicate hotkey ids. Oh well. + + auto set = hotkeys_.find(key); + if (set == hotkeys_.end()) { + hotkeys_.emplace(key, HotKeySet{key}); + set = hotkeys_.find(key); + } + set->second.add_item(HotKeyItem(mask, id)); + + return id; +} + +void EiScreen::unregisterHotKey(uint32_t id) { + for (auto &set : hotkeys_) { + if (set.second.remove_by_id(id)) { + break; + } + } +} + +void EiScreen::fakeInputBegin() { + // FIXME -- not implemented +} + +void EiScreen::fakeInputEnd() { + // FIXME -- not implemented +} + +std::int32_t EiScreen::getJumpZoneSize() const { return 1; } + +bool EiScreen::isAnyMouseButtonDown(uint32_t &buttonID) const { return false; } + +void EiScreen::getCursorCenter(int32_t &x, int32_t &y) const { + x = x_ + w_ / 2; + y = y_ + h_ / 2; +} + +void EiScreen::fakeMouseButton(ButtonID button, bool press) { + uint32_t code; + + if (!ei_pointer_) + return; + + switch (button) { + case kButtonLeft: + code = 0x110; // BTN_LEFT + break; + case kButtonMiddle: + code = 0x112; // BTN_MIDDLE + break; + case kButtonRight: + code = 0x111; // BTN_RIGHT + break; + default: + code = 0x110 + (button - 1); + break; + } + + ei_device_button_button(ei_pointer_, code, press); + ei_device_frame(ei_pointer_, ei_now(ei_)); +} + +void EiScreen::fakeMouseMove(int32_t x, int32_t y) { + // We get one motion event before enter() with the target position + if (!is_on_screen_) { + cursor_x_ = x; + cursor_y_ = y; + return; + } + + if (!ei_abs_) + return; + + ei_device_pointer_motion_absolute(ei_abs_, x, y); + ei_device_frame(ei_abs_, ei_now(ei_)); +} + +void EiScreen::fakeMouseRelativeMove(int32_t dx, int32_t dy) const { + if (!ei_pointer_) + return; + + ei_device_pointer_motion(ei_pointer_, dx, dy); + ei_device_frame(ei_pointer_, ei_now(ei_)); +} + +void EiScreen::fakeMouseWheel(int32_t xDelta, int32_t yDelta) const { + if (!ei_pointer_) + return; + + // libei and synergy seem to use opposite directions, so we have + // to send EI the opposite of the value received if we want to remain + // compatible with other platforms (including X11). + ei_device_scroll_discrete(ei_pointer_, -xDelta, -yDelta); + ei_device_frame(ei_pointer_, ei_now(ei_)); +} + +void EiScreen::fakeKey(uint32_t keycode, bool is_down) const { + if (!ei_keyboard_) + return; + + auto xkb_keycode = keycode + 8; + key_state_->update_xkb_state(xkb_keycode, is_down); + ei_device_keyboard_key(ei_keyboard_, keycode, is_down); + ei_device_frame(ei_keyboard_, ei_now(ei_)); +} + +void EiScreen::enable() { + // Nothing really to be done here +} + +void EiScreen::disable() { + // Nothing really to be done here, maybe cleanup in the future but ideally + // that's handled elsewhere +} + +void EiScreen::enter() { + is_on_screen_ = true; + if (!is_primary_) { + ++sequence_number_; + if (ei_pointer_) { + ei_device_start_emulating(ei_pointer_, sequence_number_); + } + if (ei_keyboard_) { + ei_device_start_emulating(ei_keyboard_, sequence_number_); + } + if (ei_abs_) { + ei_device_start_emulating(ei_abs_, sequence_number_); + fakeMouseMove(cursor_x_, cursor_y_); + } + } +#if HAVE_LIBPORTAL_INPUTCAPTURE + else { + LOG_DEBUG("releasing input capture at x=%i y=%i", cursor_x_, cursor_y_); + portal_input_capture_->release(cursor_x_, cursor_y_); + } +#endif +} + +bool EiScreen::leave() { + if (!is_primary_) { + if (ei_pointer_) { + ei_device_stop_emulating(ei_pointer_); + } + if (ei_keyboard_) { + ei_device_stop_emulating(ei_keyboard_); + } + if (ei_abs_) { + ei_device_stop_emulating(ei_abs_); + } + } + + is_on_screen_ = false; + return true; +} + +bool EiScreen::setClipboard(ClipboardID id, const IClipboard *clipboard) { + return false; +} + +void EiScreen::checkClipboards() { + // do nothing, we're always up to date +} + +void EiScreen::openScreensaver(bool notify) { + // FIXME +} + +void EiScreen::closeScreensaver() { + // FIXME +} + +void EiScreen::screensaver(bool activate) { + // FIXME +} + +void EiScreen::resetOptions() { + // Should reset options to neutral, see setOptions(). + // We don't have ei-specific options, nothing to do here +} + +void EiScreen::setOptions(const OptionsList &options) { + // We don't have ei-specific options, nothing to do here +} + +void EiScreen::setSequenceNumber(uint32_t seqNum) { + // FIXME: what is this used for? +} + +bool EiScreen::isPrimary() const { return is_primary_; } + +void EiScreen::update_shape() { + + for (auto it = ei_devices_.begin(); it != ei_devices_.end(); it++) { + auto idx = 0; + struct ei_region *r; + while ((r = ei_device_get_region(*it, idx++)) != nullptr) { + x_ = std::min(ei_region_get_x(r), x_); + y_ = std::min(ei_region_get_y(r), y_); + w_ = std::max(ei_region_get_x(r) + ei_region_get_width(r), w_); + h_ = std::max(ei_region_get_y(r) + ei_region_get_height(r), h_); + } + } + + LOG_NOTE("logical output size: %dx%d@%d.%d", w_, h_, x_, y_); + cursor_x_ = x_ + w_ / 2; + cursor_y_ = y_ + h_ / 2; + + sendEvent(events_->forIScreen().shapeChanged(), nullptr); +} + +void EiScreen::add_device(struct ei_device *device) { + LOG_DEBUG("adding device %s", ei_device_get_name(device)); + + // Noteworthy: EI in principle supports multiple devices with multiple + // capabilities, so there may be more than one logical pointer (or even + // multiple seats). Supporting this is ... tricky so for now we go the easy + // route: one device for each capability. Note this may be the same device + // if the first device comes with multiple capabilities. + + if (!ei_pointer_ && ei_device_has_capability(device, EI_DEVICE_CAP_POINTER) && + ei_device_has_capability(device, EI_DEVICE_CAP_BUTTON) && + ei_device_has_capability(device, EI_DEVICE_CAP_SCROLL)) { + ei_pointer_ = ei_device_ref(device); + } + + if (!ei_keyboard_ && + ei_device_has_capability(device, EI_DEVICE_CAP_KEYBOARD)) { + ei_keyboard_ = ei_device_ref(device); + + struct ei_keymap *keymap = ei_device_keyboard_get_keymap(device); + if (keymap && ei_keymap_get_type(keymap) == EI_KEYMAP_TYPE_XKB) { + int fd = ei_keymap_get_fd(keymap); + size_t len = ei_keymap_get_size(keymap); + key_state_->init(fd, len); + } else { + // We rely on the EIS implementation to give us a keymap, otherwise we + // really have no idea what a keycode means (other than it's linux/input.h + // code) Where the EIS implementation does not tell us, we just default to + // whatever libxkbcommon thinks is default. At least this way we can + // influence with env vars what we get + LOG_WARN( + "keyboard device %s does not have a keymap, we are guessing", + ei_device_get_name(device)); + key_state_->init_default_keymap(); + } + key_state_->updateKeyMap(); + } + + if (!ei_abs_ && + ei_device_has_capability(device, EI_DEVICE_CAP_POINTER_ABSOLUTE) && + ei_device_has_capability(device, EI_DEVICE_CAP_BUTTON) && + ei_device_has_capability(device, EI_DEVICE_CAP_SCROLL)) { + ei_abs_ = ei_device_ref(device); + } + + ei_devices_.emplace_back(ei_device_ref(device)); + + update_shape(); +} + +void EiScreen::remove_device(struct ei_device *device) { + LOG_DEBUG("removing device %s", ei_device_get_name(device)); + + if (device == ei_pointer_) + ei_pointer_ = ei_device_unref(ei_pointer_); + if (device == ei_keyboard_) + ei_keyboard_ = ei_device_unref(ei_keyboard_); + if (device == ei_abs_) + ei_abs_ = ei_device_unref(ei_abs_); + + for (auto it = ei_devices_.begin(); it != ei_devices_.end(); it++) { + if (*it == device) { + ei_devices_.erase(it); + ei_device_unref(device); + break; + } + } + + update_shape(); +} + +void EiScreen::sendEvent(Event::Type type, void *data) { + events_->addEvent(Event(type, getEventTarget(), data)); +} + +ButtonID EiScreen::map_button_from_evdev(ei_event *event) const { + uint32_t button = ei_event_button_get_button(event); + + switch (button) { + case 0x110: + return kButtonLeft; + case 0x111: + return kButtonRight; + case 0x112: + return kButtonMiddle; + case 0x113: + return kButtonExtra0; + case 0x114: + return kButtonExtra1; + default: + return kButtonNone; + } + + return kButtonNone; +} + +bool EiScreen::on_hotkey(KeyID keyid, bool is_pressed, KeyModifierMask mask) { + auto it = hotkeys_.find(keyid); + + if (it == hotkeys_.end()) { + return false; + } + + // Note: our mask (see on_key_event) only contains some modifiers + // but we don't put a limitation on modifiers in the hotkeys. So some + // key combinations may not work correctly, more effort is needed here. + auto id = it->second.find_by_mask(mask); + if (id != 0) { + Event::Type type = is_pressed ? events_->forIPrimaryScreen().hotKeyDown() + : events_->forIPrimaryScreen().hotKeyUp(); + sendEvent(type, HotKeyInfo::alloc(id)); + return true; + } + + return false; +} + +void EiScreen::on_key_event(ei_event *event) { + uint32_t keycode = ei_event_keyboard_get_key(event); + uint32_t keyval = keycode + 8; + bool pressed = ei_event_keyboard_get_key_is_press(event); + KeyID keyid = key_state_->map_key_from_keyval(keyval); + KeyButton keybutton = static_cast(keyval); + + key_state_->update_xkb_state(keyval, pressed); + KeyModifierMask mask = key_state_->pollActiveModifiers(); + + LOG_DEBUG1( + "event: key %s keycode=%d keyid=%d mask=0x%x", + pressed ? "press" : "release", keycode, keyid, mask); + + if (is_primary_ && on_hotkey(keyid, pressed, mask)) { + return; + } + + if (keyid != kKeyNone) { + key_state_->sendKeyEvent( + getEventTarget(), pressed, false, keyid, mask, 1, keybutton); + } +} + +void EiScreen::on_button_event(ei_event *event) { + assert(is_primary_); + + ButtonID button = map_button_from_evdev(event); + bool pressed = ei_event_button_get_is_press(event); + KeyModifierMask mask = key_state_->pollActiveModifiers(); + + LOG_DEBUG1( + "event: button %s button=%d mask=0x%x", pressed ? "press" : "release", + button, mask); + + if (button == kButtonNone) { + LOG_DEBUG("event: button not recognized"); + return; + } + + auto eventType = pressed ? events_->forIPrimaryScreen().buttonDown() + : events_->forIPrimaryScreen().buttonUp(); + + sendEvent(eventType, ButtonInfo::alloc(button, mask)); +} + +void EiScreen::on_pointer_scroll_event(ei_event *event) { + // Ratio of 10 pixels == one wheel click because that's what mutter/gtk + // use (for historical reasons). + const int PIXELS_PER_WHEEL_CLICK = 10; + // Our logical wheel clicks are multiples 120, so we + // convert between the two and keep the remainders because + // we will very likely get subpixel scroll events. + // This means a single pixel is 120/PIXEL_TO_WHEEL_RATIO in wheel values. + const int PIXEL_TO_WHEEL_RATIO = 120 / PIXELS_PER_WHEEL_CLICK; + + assert(is_primary_); + + double dx = ei_event_scroll_get_dx(event); + double dy = ei_event_scroll_get_dy(event); + struct ei_device *device = ei_event_get_device(event); + + LOG_DEBUG1("event: scroll (%.2f, %.2f)", dx, dy); + + struct ScrollRemainder *remainder = + static_cast(ei_device_get_user_data(device)); + if (!remainder) { + remainder = new ScrollRemainder(); + ei_device_set_user_data(device, remainder); + } + + dx += remainder->x; + dy += remainder->y; + + double x, y; + double rx = modf(dx, &x); + double ry = modf(dy, &y); + + assert(!std::isnan(x) && !std::isinf(x)); + assert(!std::isnan(y) && !std::isinf(y)); + + // libei and synergy seem to use opposite directions, so we have + // to send the opposite of the value reported by EI if we want to + // remain compatible with other platforms (including X11). + if (x != 0 || y != 0) + sendEvent( + events_->forIPrimaryScreen().wheel(), + WheelInfo::alloc( + (int32_t)-x * PIXEL_TO_WHEEL_RATIO, + (int32_t)-y * PIXEL_TO_WHEEL_RATIO)); + + remainder->x = rx; + remainder->y = ry; +} + +void EiScreen::on_pointer_scroll_discrete_event(ei_event *event) { + // both libei and synergy use multiples of 120 to represent + // one scroll wheel click event so we can just forward things + // as-is. + + assert(is_primary_); + + std::int32_t dx = ei_event_scroll_get_discrete_dx(event); + std::int32_t dy = ei_event_scroll_get_discrete_dy(event); + + LOG_DEBUG1("event: scroll discrete (%d, %d)", dx, dy); + + // libei and synergy seem to use opposite directions, so we have + // to send the opposite of the value reported by EI if we want to + // remain compatible with other platforms (including X11). + sendEvent(events_->forIPrimaryScreen().wheel(), WheelInfo::alloc(-dx, -dy)); +} + +void EiScreen::on_motion_event(ei_event *event) { + assert(is_primary_); + + double dx = ei_event_pointer_get_dx(event); + double dy = ei_event_pointer_get_dy(event); + + if (is_on_screen_) { + LOG_DEBUG("event: motion on primary x=%i y=%i)", cursor_x_, cursor_y_); + sendEvent( + events_->forIPrimaryScreen().motionOnPrimary(), + MotionInfo::alloc(cursor_x_, cursor_y_)); + +#if HAVE_LIBPORTAL_INPUTCAPTURE + if (portal_input_capture_->is_active()) { + portal_input_capture_->release(); + } +#endif + } else { + buffer_dx += dx; + buffer_dy += dy; + auto pixel_dx = static_cast(buffer_dx); + auto pixel_dy = static_cast(buffer_dy); + if (pixel_dx || pixel_dy) { + LOG_DEBUG("event: motion on secondary x=%d y=%d", pixel_dx, pixel_dy); + sendEvent( + events_->forIPrimaryScreen().motionOnSecondary(), + MotionInfo::alloc(pixel_dx, pixel_dy)); + buffer_dx -= pixel_dx; + buffer_dy -= pixel_dy; + } + } +} + +void EiScreen::on_abs_motion_event(ei_event *event) { assert(is_primary_); } + +void EiScreen::handle_connected_to_eis_event(const Event &event, void *) { + int fd = static_cast(event.getData())->m_fd; + LOG_DEBUG("eis connection established, fd=%d", fd); + + auto rc = ei_setup_backend_fd(ei_, fd); + if (rc != 0) { + LOG_NOTE("failed to set up ei: %s", strerror(-rc)); + } +} + +void EiScreen::handle_portal_session_closed(const Event &event, void *) { + // Portal may or may EI_EVENT_DISCONNECT us before sending the DBus Closed + // signal Let's clean up either way. + cleanup_ei(); + init_ei(); +} + +void EiScreen::handleSystemEvent(const Event &sysevent, void *) { + std::lock_guard lock(mutex_); + bool disconnected = false; + + // Only one ei_dispatch per system event, see the comment in + // EiEventQueueBuffer::addEvent + ei_dispatch(ei_); + struct ei_event *event; + + while ((event = ei_get_event(ei_)) != nullptr) { + auto type = ei_event_get_type(event); + auto seat = ei_event_get_seat(event); + auto device = ei_event_get_device(event); + + switch (type) { + case EI_EVENT_CONNECT: + LOG_DEBUG("connected to eis"); + break; + case EI_EVENT_SEAT_ADDED: + if (!ei_seat_) { + ei_seat_ = ei_seat_ref(seat); + ei_seat_bind_capabilities( + ei_seat_, EI_DEVICE_CAP_POINTER, EI_DEVICE_CAP_POINTER_ABSOLUTE, + EI_DEVICE_CAP_KEYBOARD, EI_DEVICE_CAP_BUTTON, EI_DEVICE_CAP_SCROLL, + nullptr); + LOG_DEBUG("ei: using seat %s", ei_seat_get_name(ei_seat_)); + // we don't care about touch + } + break; + case EI_EVENT_DEVICE_ADDED: + if (seat == ei_seat_) { + add_device(device); + } else { + LOG_INFO("seat %s is ignored", ei_seat_get_name(ei_seat_)); + } + break; + case EI_EVENT_DEVICE_REMOVED: + remove_device(device); + break; + case EI_EVENT_SEAT_REMOVED: + if (seat == ei_seat_) { + ei_seat_ = ei_seat_unref(ei_seat_); + } + break; + case EI_EVENT_DISCONNECT: + // We're using libei which emulates the various seat/device remove events + // so by the time we get here our EiScreen should be in a neutral state. + // + // We don't do anything here, we let the portal's Session.Closed signal + // handle the rest. + LOG_WARN("disconnected from eis"); + disconnected = true; + break; + case EI_EVENT_DEVICE_PAUSED: + LOG_DEBUG("device %s is paused", ei_device_get_name(device)); + break; + case EI_EVENT_DEVICE_RESUMED: + LOG_DEBUG("device %s is resumed", ei_device_get_name(device)); + if (!is_primary_ && is_on_screen_) { + ei_device_start_emulating(device, ++sequence_number_); + } + break; + case EI_EVENT_KEYBOARD_MODIFIERS: + // FIXME + break; + + // events below are for a receiver context (barriers) + case EI_EVENT_FRAME: + break; + case EI_EVENT_DEVICE_START_EMULATING: + LOG_DEBUG("device %s starts emulating", ei_device_get_name(device)); + break; + case EI_EVENT_DEVICE_STOP_EMULATING: + LOG_DEBUG("device %s stops emulating", ei_device_get_name(device)); + break; + case EI_EVENT_KEYBOARD_KEY: + on_key_event(event); + break; + case EI_EVENT_BUTTON_BUTTON: + on_button_event(event); + break; + case EI_EVENT_POINTER_MOTION: + on_motion_event(event); + break; + case EI_EVENT_POINTER_MOTION_ABSOLUTE: + on_abs_motion_event(event); + break; + case EI_EVENT_TOUCH_UP: + break; + case EI_EVENT_TOUCH_MOTION: + break; + case EI_EVENT_TOUCH_DOWN: + break; + case EI_EVENT_SCROLL_DELTA: + on_pointer_scroll_event(event); + break; + case EI_EVENT_SCROLL_DISCRETE: + on_pointer_scroll_discrete_event(event); + break; + case EI_EVENT_SCROLL_STOP: + case EI_EVENT_SCROLL_CANCEL: + break; + } + ei_event_unref(event); + } + + if (disconnected) + ei_ = ei_unref(ei_); +} + +void EiScreen::updateButtons() { + // libei relies on the EIS implementation to keep our button count correct, + // so there's not much we need to/can do here. +} + +IKeyState *EiScreen::getKeyState() const { return key_state_; } + +String EiScreen::getSecureInputApp() const { + throw std::runtime_error("Not implemented"); +} + +EiScreen::HotKeyItem::HotKeyItem(std::uint32_t mask, std::uint32_t id) + : mask_(mask), + id_(id) {} + +EiScreen::HotKeySet::HotKeySet(KeyID key) : id_(key) {} + +bool EiScreen::HotKeySet::remove_by_id(std::uint32_t id) { + for (auto it = set_.begin(); it != set_.end(); ++it) { + if (it->id_ == id) { + set_.erase(it); + return true; + } + } + return false; +} + +void EiScreen::HotKeySet::add_item(HotKeyItem item) { set_.push_back(item); } + +std::uint32_t EiScreen::HotKeySet::find_by_mask(std::uint32_t mask) const { + for (const auto &item : set_) { + if (item.mask_ == mask) { + return item.id_; + } + } + return 0; +} + +} // namespace synergy diff --git a/src/lib/platform/EiScreen.h b/src/lib/platform/EiScreen.h new file mode 100644 index 000000000..973b16f2d --- /dev/null +++ b/src/lib/platform/EiScreen.h @@ -0,0 +1,201 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "config.h" +#include "synergy/KeyMap.h" +#include "synergy/PlatformScreen.h" + +#include +#include +#include +#include + +struct ei; +struct ei_event; +struct ei_seat; +struct ei_device; + +namespace synergy { + +class EiClipboard; +class EiKeyState; +class PortalRemoteDesktop; +#if HAVE_LIBPORTAL_INPUTCAPTURE +class PortalInputCapture; +#endif + +//! Implementation of IPlatformScreen for X11 +class EiScreen : public PlatformScreen { +public: + EiScreen(bool is_primary, IEventQueue *events, bool use_portal); + ~EiScreen(); + + // IScreen overrides + void *getEventTarget() const override; + bool getClipboard(ClipboardID id, IClipboard *) const override; + void getShape( + std::int32_t &x, std::int32_t &y, std::int32_t &width, + std::int32_t &height) const override; + void getCursorPos(std::int32_t &x, std::int32_t &y) const override; + + // IPrimaryScreen overrides + void reconfigure(std::uint32_t activeSides) override; + void warpCursor(std::int32_t x, std::int32_t y) override; + std::uint32_t registerHotKey(KeyID key, KeyModifierMask mask) override; + void unregisterHotKey(std::uint32_t id) override; + void fakeInputBegin() override; + void fakeInputEnd() override; + std::int32_t getJumpZoneSize() const override; + bool isAnyMouseButtonDown(std::uint32_t &buttonID) const override; + void getCursorCenter(std::int32_t &x, std::int32_t &y) const override; + + // ISecondaryScreen overrides + void fakeMouseButton(ButtonID id, bool press) override; + void fakeMouseMove(std::int32_t x, std::int32_t y) override; + void fakeMouseRelativeMove(std::int32_t dx, std::int32_t dy) const override; + void fakeMouseWheel(std::int32_t xDelta, std::int32_t yDelta) const override; + void fakeKey(std::uint32_t keycode, bool is_down) const; + + // IPlatformScreen overrides + void enable() override; + void disable() override; + void enter() override; + bool leave() override; + bool setClipboard(ClipboardID, const IClipboard *) override; + void checkClipboards() override; + void openScreensaver(bool notify) override; + void closeScreensaver() override; + void screensaver(bool activate) override; + void resetOptions() override; + void setOptions(const OptionsList &options) override; + void setSequenceNumber(std::uint32_t) override; + bool isPrimary() const override; + +protected: + // IPlatformScreen overrides + void handleSystemEvent(const Event &event, void *) override; + void updateButtons() override; + IKeyState *getKeyState() const override; + String getSecureInputApp() const override; + + void update_shape(); + void add_device(ei_device *device); + void remove_device(ei_device *device); + +private: + void init_ei(); + void cleanup_ei(); + void sendEvent(Event::Type type, void *data); + ButtonID map_button_from_evdev(ei_event *event) const; + void on_key_event(ei_event *event); + void on_button_event(ei_event *event); + void send_wheel_events( + ei_device *device, const int threshold, double dx, double dy, + bool is_discrete); + void on_pointer_scroll_event(ei_event *event); + void on_pointer_scroll_discrete_event(ei_event *event); + void on_motion_event(ei_event *event); + void on_abs_motion_event(ei_event *event); + bool on_hotkey(KeyID key, bool is_press, KeyModifierMask mask); + void handle_ei_log_event( + ei *ei, ei_log_priority priority, const char *message, + ei_log_context *context); + void handle_connected_to_eis_event(const Event &event, void *); + void handle_portal_session_closed(const Event &event, void *); + + static void cb_handle_ei_log_event( + ei *ei, ei_log_priority priority, const char *message, + ei_log_context *context) { + auto screen = reinterpret_cast(ei_get_user_data(ei)); + screen->handle_ei_log_event(ei, priority, message, context); + } + +private: + // true if screen is being used as a primary screen, false otherwise + bool is_primary_ = false; + IEventQueue *events_ = nullptr; + + // keyboard stuff + EiKeyState *key_state_ = nullptr; + + std::vector ei_devices_; + + ei *ei_ = nullptr; + ei_seat *ei_seat_ = nullptr; + ei_device *ei_pointer_ = nullptr; + ei_device *ei_keyboard_ = nullptr; + ei_device *ei_abs_ = nullptr; + + std::uint32_t sequence_number_ = 0; + + std::uint32_t x_ = 0; + std::uint32_t y_ = 0; + std::uint32_t w_ = 0; + std::uint32_t h_ = 0; + + // true if mouse has entered the screen + bool is_on_screen_; + + // server: last pointer position + // client: position sent before enter() + std::int32_t cursor_x_ = 0; + std::int32_t cursor_y_ = 0; + + double buffer_dx = 0; + double buffer_dy = 0; + + mutable std::mutex mutex_; + + PortalRemoteDesktop *portal_remote_desktop_ = nullptr; +#if HAVE_LIBPORTAL_INPUTCAPTURE + PortalInputCapture *portal_input_capture_ = nullptr; +#endif + + struct HotKeyItem { + public: + HotKeyItem(std::uint32_t mask, std::uint32_t id); + bool operator<(const HotKeyItem &other) const { + return mask_ < other.mask_; + }; + + public: + std::uint32_t mask_ = 0; + std::uint32_t id_ = 0; // for registering the hotkey + }; + + class HotKeySet { + public: + HotKeySet(KeyID keyid); + KeyID keyid() const { return id_; }; + bool remove_by_id(std::uint32_t id); + void add_item(HotKeyItem item); + std::uint32_t find_by_mask(std::uint32_t mask) const; + + private: + KeyID id_ = 0; + std::vector set_; + }; + + using HotKeyMap = std::map; + + HotKeyMap hotkeys_; +}; + +} // namespace synergy diff --git a/src/lib/platform/PortalInputCapture.cpp b/src/lib/platform/PortalInputCapture.cpp new file mode 100644 index 000000000..86abe77b0 --- /dev/null +++ b/src/lib/platform/PortalInputCapture.cpp @@ -0,0 +1,409 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "config.h" + +#if HAVE_LIBPORTAL_INPUTCAPTURE + +#include "base/Event.h" +#include "base/Log.h" +#include "base/TMethodJob.h" +#include "platform/PortalInputCapture.h" + +#include // for EIS fd hack, remove +#include // for EIS fd hack, remove + +namespace synergy { + +enum signals { + SESSION_CLOSED, + DISABLED, + ACTIVATED, + DEACTIVATED, + ZONES_CHANGED, + _N_SIGNALS, +}; + +PortalInputCapture::PortalInputCapture(EiScreen *screen, IEventQueue *events) + : screen_(screen), + events_(events), + portal_(xdp_portal_new()), + signals_(_N_SIGNALS) { + glib_main_loop_ = g_main_loop_new(nullptr, true); + glib_thread_ = new Thread(new TMethodJob( + this, &PortalInputCapture::glib_thread)); + + auto init_capture_cb = [](gpointer data) -> gboolean { + return reinterpret_cast(data) + ->init_input_capture_session(); + }; + + g_idle_add(init_capture_cb, this); +} + +PortalInputCapture::~PortalInputCapture() { + if (g_main_loop_is_running(glib_main_loop_)) + g_main_loop_quit(glib_main_loop_); + + if (glib_thread_) { + glib_thread_->cancel(); + glib_thread_->wait(); + glib_thread_ = nullptr; + + g_main_loop_unref(glib_main_loop_); + glib_main_loop_ = nullptr; + } + + if (session_) { + XdpSession *parent_session = + xdp_input_capture_session_get_session(session_); + g_signal_handler_disconnect( + G_OBJECT(parent_session), signals_[SESSION_CLOSED]); + g_signal_handler_disconnect(session_, signals_[DISABLED]); + g_signal_handler_disconnect(session_, signals_[ACTIVATED]); + g_signal_handler_disconnect(session_, signals_[DEACTIVATED]); + g_signal_handler_disconnect(session_, signals_[ZONES_CHANGED]); + g_object_unref(session_); + } + + for (auto b : barriers_) { + g_object_unref(b); + } + barriers_.clear(); + g_object_unref(portal_); +} + +gboolean PortalInputCapture::timeout_handler() { + return true; // keep re-triggering +} + +int PortalInputCapture::fake_eis_fd() { + auto path = std::getenv("LIBEI_SOCKET"); + + if (!path) { + LOG_DEBUG("cannot fake eis socket, env var not set: LIBEI_SOCKET"); + return -1; + } + + auto sock = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0); + + // Dealing with the socket directly because nothing in lib/... supports + // AF_UNIX and I'm too lazy to fix all this for a temporary hack + int fd = sock; + struct sockaddr_un addr = { + .sun_family = AF_UNIX, + .sun_path = {0}, + }; + std::snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path); + + auto result = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + if (result != 0) { + LOG_DEBUG("faked eis fd failed: %s", strerror(errno)); + } + + return sock; +} + +void PortalInputCapture::cb_session_closed(XdpSession *session) { + LOG_ERR("portal input capture session was closed, exiting"); + g_main_loop_quit(glib_main_loop_); + events_->addEvent(Event::kQuit); + + g_signal_handler_disconnect(session, signals_[SESSION_CLOSED]); + signals_[SESSION_CLOSED] = 0; +} + +void PortalInputCapture::cb_init_input_capture_session( + GObject *object, GAsyncResult *res) { + LOG_DEBUG("portal session ready"); + g_autoptr(GError) error = nullptr; + + auto session = xdp_portal_create_input_capture_session_finish( + XDP_PORTAL(object), res, &error); + if (!session) { + LOG_ERR( + "failed to initialize input capture session, quitting: %s", + error->message); + g_main_loop_quit(glib_main_loop_); + events_->addEvent(Event::kQuit); + return; + } + + session_ = session; + + auto fd = xdp_input_capture_session_connect_to_eis(session, &error); + if (fd < 0) { + LOG_ERR("failed to connect to eis: %s", error->message); + + // FIXME: Development hack to avoid having to assemble all parts just for + // testing this code. + fd = fake_eis_fd(); + + if (fd < 0) { + g_main_loop_quit(glib_main_loop_); + events_->addEvent(Event::kQuit); + return; + } + } + // Socket ownership is transferred to the EiScreen + events_->addEvent(Event( + events_->forEi().connected(), screen_->getEventTarget(), + EiScreen::EiConnectInfo::alloc(fd))); + + // FIXME: the lambda trick doesn't work here for unknown reasons, we need + // the static function + signals_[DISABLED] = g_signal_connect( + G_OBJECT(session), "disabled", G_CALLBACK(cb_disabled_cb), this); + signals_[ACTIVATED] = g_signal_connect( + G_OBJECT(session_), "activated", G_CALLBACK(cb_activated_cb), this); + signals_[DEACTIVATED] = g_signal_connect( + G_OBJECT(session_), "deactivated", G_CALLBACK(cb_deactivated_cb), this); + signals_[ZONES_CHANGED] = g_signal_connect( + G_OBJECT(session_), "zones-changed", G_CALLBACK(cb_zones_changed_cb), + this); + XdpSession *parent_session = xdp_input_capture_session_get_session(session); + signals_[SESSION_CLOSED] = g_signal_connect( + G_OBJECT(parent_session), "closed", G_CALLBACK(cb_session_closed_cb), + this); + + cb_zones_changed(session_, nullptr); +} + +void PortalInputCapture::cb_set_pointer_barriers( + GObject *object, GAsyncResult *res) { + g_autoptr(GError) error = nullptr; + + auto failed_list = xdp_input_capture_session_set_pointer_barriers_finish( + session_, res, &error); + if (failed_list) { + auto it = failed_list; + while (it) { + guint id; + g_object_get(it->data, "id", &id, nullptr); + + for (auto elem = barriers_.begin(); elem != barriers_.end(); elem++) { + if (*elem == it->data) { + int x1, x2, y1, y2; + + g_object_get( + G_OBJECT(*elem), "x1", &x1, "x2", &x2, "y1", &y1, "y2", &y2, + nullptr); + + LOG_WARN( + "failed to apply barrier %d (%d/%d-%d/%d)", id, x1, y1, x2, y2); + g_object_unref(*elem); + barriers_.erase(elem); + break; + } + } + it = it->next; + } + } + g_list_free_full(failed_list, g_object_unref); + + enable(); +} + +gboolean PortalInputCapture::init_input_capture_session() { + LOG_DEBUG("setting up input capture session"); + xdp_portal_create_input_capture_session( + portal_, + nullptr, // parent + static_cast( + XDP_INPUT_CAPABILITY_KEYBOARD | XDP_INPUT_CAPABILITY_POINTER), + nullptr, // cancellable + [](GObject *obj, GAsyncResult *res, gpointer data) { + reinterpret_cast(data) + ->cb_init_input_capture_session(obj, res); + }, + this); + + return false; +} + +void PortalInputCapture::enable() { + if (!enabled_) { + LOG_DEBUG("enabling the portal input capture session"); + xdp_input_capture_session_enable(session_); + enabled_ = true; + } +} + +void PortalInputCapture::disable() { + if (enabled_) { + LOG_DEBUG("disabling the portal input capture session"); + xdp_input_capture_session_disable(session_); + enabled_ = false; + } +} + +void PortalInputCapture::release() { + LOG_DEBUG("releasing input capture session, id=%d", activation_id_); + xdp_input_capture_session_release(session_, activation_id_); + is_active_ = false; +} + +void PortalInputCapture::release(double x, double y) { + LOG_DEBUG( + "releasing input capture session, id=%d x=%.1f y=%.1f", activation_id_, x, + y); + xdp_input_capture_session_release_at(session_, activation_id_, x, y); + is_active_ = false; +} + +void PortalInputCapture::cb_disabled(XdpInputCaptureSession *session) { + LOG_DEBUG("portal cb disabled"); + + if (!enabled_) + return; // Nothing to do + + enabled_ = false; + is_active_ = false; + + // FIXME: need some better heuristics here of when we want to enable again + // But we don't know *why* we got disabled (and it's doubtfull we ever + // will), so we just assume that the zones will change or something and we + // can re-enable again + // ... very soon + g_timeout_add( + 1000, + [](gpointer data) -> gboolean { + reinterpret_cast(data)->enable(); + return false; + }, + this); +} + +void PortalInputCapture::cb_activated( + XdpInputCaptureSession *session, std::uint32_t activation_id, + GVariant *options) { + LOG_DEBUG("portal cb activated, id=%d", activation_id); + + if (options) { + gdouble x, y; + if (g_variant_lookup(options, "cursor_position", "(dd)", &x, &y)) { + screen_->warpCursor((int)x, (int)y); + } else { + LOG_WARN("failed to get cursor position"); + } + } else { + LOG_WARN("activation has no options"); + } + activation_id_ = activation_id; + is_active_ = true; +} + +void PortalInputCapture::cb_deactivated( + XdpInputCaptureSession *session, std::uint32_t activation_id, + GVariant *options) { + LOG_DEBUG("cb deactivated, id=%i", activation_id); + is_active_ = false; +} + +void PortalInputCapture::cb_zones_changed( + XdpInputCaptureSession *session, GVariant *options) { + for (auto b : barriers_) + g_object_unref(b); + barriers_.clear(); + + auto zones = xdp_input_capture_session_get_zones(session); + while (zones != nullptr) { + guint w, h; + gint x, y; + g_object_get( + zones->data, "width", &w, "height", &h, "x", &x, "y", &y, nullptr); + + LOG_DEBUG("input capture zone, %dx%d@%d,%d", w, h, x, y); + + int x1, x2, y1, y2; + + // Hardcoded behaviour: our pointer barriers are always at the edges of + // all zones. Since the implementation is supposed to reject the ones in + // the wrong place, we can just install barriers everywhere and let EIS + // figure it out. Also a lot easier to implement for now though it doesn't + // cover differently-sized screens... + auto id = barriers_.size() + 1; + x1 = x; + y1 = y; + x2 = x + w - 1; + y2 = y; + LOG_DEBUG("barrier (top) %zd at %d,%d-%d,%d", id, x1, y1, x2, y2); + barriers_.push_back(XDP_INPUT_CAPTURE_POINTER_BARRIER(g_object_new( + XDP_TYPE_INPUT_CAPTURE_POINTER_BARRIER, "id", id, "x1", x1, "y1", y1, + "x2", x2, "y2", y2, nullptr))); + id = barriers_.size() + 1; + x1 = x + w; + y1 = y; + x2 = x + w; + y2 = y + h - 1; + LOG_DEBUG("barrier (right) %zd at %d,%d-%d,%d", id, x1, y1, x2, y2); + barriers_.push_back(XDP_INPUT_CAPTURE_POINTER_BARRIER(g_object_new( + XDP_TYPE_INPUT_CAPTURE_POINTER_BARRIER, "id", id, "x1", x1, "y1", y1, + "x2", x2, "y2", y2, nullptr))); + id = barriers_.size() + 1; + x1 = x; + y1 = y; + x2 = x; + y2 = y + h - 1; + LOG_DEBUG("barrier (left) %zd at %d,%d-%d,%d", id, x1, y1, x2, y2); + barriers_.push_back(XDP_INPUT_CAPTURE_POINTER_BARRIER(g_object_new( + XDP_TYPE_INPUT_CAPTURE_POINTER_BARRIER, "id", id, "x1", x1, "y1", y1, + "x2", x2, "y2", y2, nullptr))); + id = barriers_.size() + 1; + x1 = x; + y1 = y + h; + x2 = x + w - 1; + y2 = y + h; + LOG_DEBUG("barrier (bottom) %zd at %d,%d-%d,%d", id, x1, y1, x2, y2); + barriers_.push_back(XDP_INPUT_CAPTURE_POINTER_BARRIER(g_object_new( + XDP_TYPE_INPUT_CAPTURE_POINTER_BARRIER, "id", id, "x1", x1, "y1", y1, + "x2", x2, "y2", y2, nullptr))); + zones = zones->next; + } + + GList *list = nullptr; + for (auto const &b : barriers_) { + list = g_list_append(list, b); + } + + xdp_input_capture_session_set_pointer_barriers( + session_, list, + nullptr, // cancellable + [](GObject *obj, GAsyncResult *res, gpointer data) { + reinterpret_cast(data)->cb_set_pointer_barriers( + obj, res); + }, + this); +} + +void PortalInputCapture::glib_thread(void *) { + auto context = g_main_loop_get_context(glib_main_loop_); + + LOG_DEBUG("glib thread running"); + + while (g_main_loop_is_running(glib_main_loop_)) { + Thread::testCancel(); + g_main_context_iteration(context, true); + } + + LOG_DEBUG("shutting down glib thread"); +} + +} // namespace synergy + +#endif diff --git a/src/lib/platform/PortalInputCapture.h b/src/lib/platform/PortalInputCapture.h new file mode 100644 index 000000000..5f190f565 --- /dev/null +++ b/src/lib/platform/PortalInputCapture.h @@ -0,0 +1,109 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "config.h" + +#if HAVE_LIBPORTAL_INPUTCAPTURE + +#include "mt/Thread.h" +#include "platform/EiScreen.h" + +#include +#include +#include +#include + +namespace synergy { + +class PortalInputCapture { +public: + PortalInputCapture(EiScreen *screen, IEventQueue *events); + ~PortalInputCapture(); + void enable(); + void disable(); + void release(); + void release(double x, double y); + bool is_active() const { return is_active_; } + +private: + void glib_thread(void *); + gboolean timeout_handler(); + gboolean init_input_capture_session(); + void cb_init_input_capture_session(GObject *object, GAsyncResult *res); + void cb_set_pointer_barriers(GObject *object, GAsyncResult *res); + void cb_session_closed(XdpSession *session); + void cb_disabled(XdpInputCaptureSession *session); + void cb_activated( + XdpInputCaptureSession *session, std::uint32_t activation_id, + GVariant *options); + void cb_deactivated( + XdpInputCaptureSession *session, std::uint32_t activation_id, + GVariant *options); + void cb_zones_changed(XdpInputCaptureSession *session, GVariant *options); + + /// g_signal_connect callback wrapper + static void cb_session_closed_cb(XdpSession *session, gpointer data) { + reinterpret_cast(data)->cb_session_closed(session); + } + static void cb_disabled_cb(XdpInputCaptureSession *session, gpointer data) { + reinterpret_cast(data)->cb_disabled(session); + } + static void cb_activated_cb( + XdpInputCaptureSession *session, std::uint32_t activation_id, + GVariant *options, gpointer data) { + reinterpret_cast(data)->cb_activated( + session, activation_id, options); + } + static void cb_deactivated_cb( + XdpInputCaptureSession *session, std::uint32_t activation_id, + GVariant *options, gpointer data) { + reinterpret_cast(data)->cb_deactivated( + session, activation_id, options); + } + static void cb_zones_changed_cb( + XdpInputCaptureSession *session, GVariant *options, gpointer data) { + reinterpret_cast(data)->cb_zones_changed( + session, options); + } + + int fake_eis_fd(); + +private: + EiScreen *screen_ = nullptr; + IEventQueue *events_ = nullptr; + + Thread *glib_thread_; + GMainLoop *glib_main_loop_ = nullptr; + + XdpPortal *portal_ = nullptr; + XdpInputCaptureSession *session_ = nullptr; + + std::vector signals_; + + bool enabled_ = false; + bool is_active_ = false; + std::uint32_t activation_id_ = 0; + + std::vector barriers_; +}; + +} // namespace synergy + +#endif // HAVE_LIBPORTAL_INPUTCAPTURE diff --git a/src/lib/platform/PortalRemoteDesktop.cpp b/src/lib/platform/PortalRemoteDesktop.cpp new file mode 100644 index 000000000..14167bcbd --- /dev/null +++ b/src/lib/platform/PortalRemoteDesktop.cpp @@ -0,0 +1,204 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "platform/PortalRemoteDesktop.h" +#include "base/Log.h" +#include "base/TMethodJob.h" + +#include // for EIS fd hack, remove +#include // for EIS fd hack, remove + +namespace synergy { + +PortalRemoteDesktop::PortalRemoteDesktop(EiScreen *screen, IEventQueue *events) + : screen_(screen), + events_(events), + portal_(xdp_portal_new()) { + glib_main_loop_ = g_main_loop_new(nullptr, true); + glib_thread_ = new Thread(new TMethodJob( + this, &PortalRemoteDesktop::glib_thread)); + + reconnect(0); +} + +PortalRemoteDesktop::~PortalRemoteDesktop() { + if (g_main_loop_is_running(glib_main_loop_)) + g_main_loop_quit(glib_main_loop_); + + if (glib_thread_ != nullptr) { + glib_thread_->cancel(); + glib_thread_->wait(); + delete glib_thread_; + glib_thread_ = nullptr; + + g_main_loop_unref(glib_main_loop_); + glib_main_loop_ = nullptr; + } + + if (session_signal_id_) + g_signal_handler_disconnect(session_, session_signal_id_); + if (session_ != nullptr) + g_object_unref(session_); + g_object_unref(portal_); + + free(session_restore_token_); +} + +gboolean PortalRemoteDesktop::timeout_handler() { + return true; // keep re-triggering +} + +void PortalRemoteDesktop::reconnect(unsigned int timeout) { + auto init_cb = [](gpointer data) -> gboolean { + return reinterpret_cast(data) + ->init_remote_desktop_session(); + }; + + if (timeout > 0) + g_timeout_add(timeout, init_cb, this); + else + g_idle_add(init_cb, this); +} + +void PortalRemoteDesktop::cb_session_closed(XdpSession *session) { + LOG_ERR("portal remote desktop session was closed, reconnecting"); + g_signal_handler_disconnect(session, session_signal_id_); + session_signal_id_ = 0; + events_->addEvent( + Event(events_->forEi().sessionClosed(), screen_->getEventTarget())); + + // gcc warning "Suspicious usage of 'sizeof(A*)'" can be ignored + g_clear_object(&session_); + + reconnect(1000); +} + +void PortalRemoteDesktop::cb_session_started( + GObject *object, GAsyncResult *res) { + g_autoptr(GError) error = nullptr; + auto session = XDP_SESSION(object); + auto success = xdp_session_start_finish(session, res, &error); + if (!success) { + LOG_ERR("failed to start portal remote desktop session"); + g_main_loop_quit(glib_main_loop_); + events_->addEvent(Event::kQuit); + return; + } + + session_restore_token_ = xdp_session_get_restore_token(session); + + // ConnectToEIS requires version 2 of the xdg-desktop-portal (and the same + // version in the impl.portal), i.e. you'll need an updated compositor on + // top of everything... + auto fd = -1; +#if HAVE_LIBPORTAL_SESSION_CONNECT_TO_EIS + fd = xdp_session_connect_to_eis(session, &error); +#endif + if (fd < 0) { + g_main_loop_quit(glib_main_loop_); + events_->addEvent(Event::kQuit); + return; + } + + // Socket ownership is transferred to the EiScreen + events_->addEvent(Event( + events_->forEi().connected(), screen_->getEventTarget(), + EiScreen::EiConnectInfo::alloc(fd))); +} + +void PortalRemoteDesktop::cb_init_remote_desktop_session( + GObject *object, GAsyncResult *res) { + LOG_DEBUG("remote desktop session ready"); + g_autoptr(GError) error = nullptr; + + auto session = xdp_portal_create_remote_desktop_session_finish( + XDP_PORTAL(object), res, &error); + if (!session) { + LOG_ERR("failed to initialize remote desktop session: %s", error->message); + // This was the first attempt to connect to the RD portal - quit if that + // fails. + if (session_iteration_ == 0) { + g_main_loop_quit(glib_main_loop_); + events_->addEvent(Event::kQuit); + } else { + this->reconnect(1000); + } + return; + } + + session_ = session; + ++session_iteration_; + + // FIXME: the lambda trick doesn't work here for unknown reasons, we need + // the static function + session_signal_id_ = g_signal_connect( + G_OBJECT(session), "closed", G_CALLBACK(cb_session_closed_cb), this); + + LOG_DEBUG("Session ready, starting"); + xdp_session_start( + session, + nullptr, // parent + nullptr, // cancellable + [](GObject *obj, GAsyncResult *res, gpointer data) { + reinterpret_cast(data)->cb_session_started( + obj, res); + }, + this); +} + +#if !defined(HAVE_LIBPORTAL_CREATE_REMOTE_DESKTOP_SESSION_FULL) +static inline void xdp_portal_create_remote_desktop_session_full( + XdpPortal *portal, XdpDeviceType devices, XdpOutputType outputs, + XdpRemoteDesktopFlags flags, XdpCursorMode cursor_mode, + XdpPersistMode _unused1, const char *_unused2, GCancellable *cancellable, + GAsyncReadyCallback callback, gpointer data) { + xdp_portal_create_remote_desktop_session( + portal, devices, outputs, flags, cursor_mode, cancellable, callback, + data); +} +#endif + +gboolean PortalRemoteDesktop::init_remote_desktop_session() { + LOG_DEBUG( + "setting up remote desktop session with restore token %s", + session_restore_token_); + xdp_portal_create_remote_desktop_session_full( + portal_, + static_cast(XDP_DEVICE_POINTER | XDP_DEVICE_KEYBOARD), + XDP_OUTPUT_NONE, XDP_REMOTE_DESKTOP_FLAG_NONE, XDP_CURSOR_MODE_HIDDEN, + XDP_PERSIST_MODE_TRANSIENT, session_restore_token_, + nullptr, // cancellable + [](GObject *obj, GAsyncResult *res, gpointer data) { + reinterpret_cast(data) + ->cb_init_remote_desktop_session(obj, res); + }, + this); + + return false; // don't reschedule +} + +void PortalRemoteDesktop::glib_thread(void *) { + auto context = g_main_loop_get_context(glib_main_loop_); + + while (g_main_loop_is_running(glib_main_loop_)) { + Thread::testCancel(); + g_main_context_iteration(context, true); + } +} + +} // namespace synergy diff --git a/src/lib/platform/PortalRemoteDesktop.h b/src/lib/platform/PortalRemoteDesktop.h new file mode 100644 index 000000000..fef0aa773 --- /dev/null +++ b/src/lib/platform/PortalRemoteDesktop.h @@ -0,0 +1,75 @@ +/* + * synergy -- mouse and keyboard sharing utility + * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2024 Symless Ltd. + * + * This package is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * found in the file LICENSE that should have accompanied this file. + * + * This package is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "config.h" + +#include "mt/Thread.h" +#include "platform/EiScreen.h" + +#include +#include + +#if !HAVE_LIBPORTAL_OUTPUT_NONE +// Added in libportal ad82a74 Jun 2022, not yet released in libportal 0.6 +// should be used as a patch on ≤ 0.6, and non-git +#define XDP_OUTPUT_NONE (XdpOutputType)0 +#endif + +namespace synergy { + +class PortalRemoteDesktop { +public: + PortalRemoteDesktop(EiScreen *screen, IEventQueue *events); + ~PortalRemoteDesktop(); + +private: + void glib_thread(void *); + gboolean timeout_handler(); + gboolean init_remote_desktop_session(); + void cb_init_remote_desktop_session(GObject *object, GAsyncResult *res); + void cb_session_started(GObject *object, GAsyncResult *res); + void cb_session_closed(XdpSession *session); + void reconnect(unsigned int timeout = 1000); + + /// g_signal_connect callback wrapper + static void cb_session_closed_cb(XdpSession *session, gpointer data) { + reinterpret_cast(data)->cb_session_closed(session); + } + + int fake_eis_fd(); + +private: + EiScreen *screen_; + IEventQueue *events_; + + Thread *glib_thread_; + GMainLoop *glib_main_loop_ = nullptr; + + XdpPortal *portal_ = nullptr; + XdpSession *session_ = nullptr; + char *session_restore_token_ = nullptr; + + guint session_signal_id_ = 0; + + /// The number of successful sessions we've had already + guint session_iteration_ = 0; +}; + +} // namespace synergy diff --git a/src/lib/synergy/App.h b/src/lib/synergy/App.h index d7a4b4d22..915b3ef36 100644 --- a/src/lib/synergy/App.h +++ b/src/lib/synergy/App.h @@ -183,8 +183,8 @@ private: " -l --log write log messages to file.\n" \ " --no-tray disable the system tray icon.\n" \ " --enable-drag-drop enable file drag & drop.\n" \ - " --enable-crypto enable the crypto (ssl) plugin.\n" \ - " --tls-cert specify the path to the tls certificate file.\n" + " --enable-crypto enable TLS encryption.\n" \ + " --tls-cert specify the path to the TLS certificate file.\n" #define HELP_COMMON_INFO_2 \ " -h, --help display this help and exit.\n" \ @@ -218,3 +218,25 @@ private: " --exit-pause wait for key press on exit, can be useful for\n" \ " reading error messages that occur on exit.\n" #endif + +#if WINAPI_LIBEI + +const auto kNoWaylandEiArg = + " --no-wayland-ei do not use EI (emulated input) for\n" + " Wayland and instead use the legacy\n" + " X Window System.\n"; +const auto kHelpNoWayland = ""; + +#elif WINAPI_XWINDOWS + +const auto kNoWaylandEiArg = ""; +const auto kHelpNoWayland = + "\n" + "Your Linux distribution does not support Wayland EI (emulated input)\n" + "which is required for Wayland support. Please use a Linux distribution\n" + "that supports Wayland EI.\n"; + +#else +const auto kNoWaylandEiArg = ""; +const auto kHelpNoWayland = ""; +#endif diff --git a/src/lib/synergy/ArgParser.cpp b/src/lib/synergy/ArgParser.cpp index 116e55099..8d79adcd7 100644 --- a/src/lib/synergy/ArgParser.cpp +++ b/src/lib/synergy/ArgParser.cpp @@ -42,7 +42,7 @@ bool ArgParser::parseServerArgs( updateCommonArgs(argv); int i = 1; while (i < argc) { - if (parsePlatformArg(args, argc, argv, i)) { + if (parsePlatformArgs(args, argc, argv, i, true)) { ++i; continue; } else if (parseGenericArgs(argc, argv, i)) { @@ -82,7 +82,7 @@ bool ArgParser::parseClientArgs( int i{1}; while (i < argc) { - if (parsePlatformArg(args, argc, argv, i)) { + if (parsePlatformArgs(args, argc, argv, i, false)) { ++i; continue; } else if (parseGenericArgs(argc, argv, i)) { @@ -137,9 +137,9 @@ bool ArgParser::parseClientArgs( return true; } -bool ArgParser::parsePlatformArg( +bool ArgParser::parsePlatformArgs( synergy::ArgsBase &argsBase, const int &argc, const char *const *argv, - int &i) { + int &i, bool isServer) { #if WINAPI_MSWINDOWS if (isArg(i, argc, argv, nullptr, "--service")) { LOG((CLOG_WARN "obsolete argument --service, use synergyd instead.")); @@ -155,6 +155,7 @@ bool ArgParser::parsePlatformArg( return true; #elif WINAPI_XWINDOWS + if (isArg(i, argc, argv, "-display", "--display", 1)) { // use alternative display argsBase.m_display = argv[++i]; @@ -164,6 +165,16 @@ bool ArgParser::parsePlatformArg( argsBase.m_disableXInitThreads = true; } +#if WINAPI_LIBEI + else if (isArg(i, argc, argv, nullptr, "--no-wayland-ei")) { + argsBase.m_disableWaylandEi = true; + } + + else if (!isServer && isArg(i, argc, argv, nullptr, "--no-wayland-portal")) { + argsBase.m_disableWaylandPortal = true; + } +#endif + else { // option not supported here return false; diff --git a/src/lib/synergy/ArgParser.h b/src/lib/synergy/ArgParser.h index a6bd34613..70e1b048b 100644 --- a/src/lib/synergy/ArgParser.h +++ b/src/lib/synergy/ArgParser.h @@ -38,9 +38,9 @@ public: parseServerArgs(synergy::ServerArgs &args, int argc, const char *const *argv); bool parseClientArgs(synergy::ClientArgs &args, int argc, const char *const *argv); - bool parsePlatformArg( + bool parsePlatformArgs( synergy::ArgsBase &argsBase, const int &argc, const char *const *argv, - int &i); + int &i, bool isServer); bool parseToolArgs(ToolArgs &args, int argc, const char *const *argv); bool parseGenericArgs(int argc, const char *const *argv, int &i); bool parseDeprecatedArgs(int argc, const char *const *argv, int &i); diff --git a/src/lib/synergy/ArgsBase.h b/src/lib/synergy/ArgsBase.h index fbdccca31..196e7cb19 100644 --- a/src/lib/synergy/ArgsBase.h +++ b/src/lib/synergy/ArgsBase.h @@ -1,7 +1,6 @@ /* * synergy -- mouse and keyboard sharing utility - * Copyright (C) 2012-2020 Symless Ltd. - * Copyright (C) 2012 Nick Bolton + * Copyright (C) 2012 Symless Ltd. * * This package is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -16,12 +15,24 @@ * along with this program. If not, see . */ -#ifndef SYNERGY_CORE_ARGSBASE_H -#define SYNERGY_CORE_ARGSBASE_H +#pragma once #include "base/String.h" namespace synergy { + +#if WINAPI_LIBEI +const auto kDisableEiDefault = false; +#else +const auto kDisableEiDefault = true; +#endif + +#if WINAPI_LIBPORTAL +const auto kDisablePortalDefault = false; +#else +const auto kDisablePortalDefault = true; +#endif + /** * @brief This is the base Argument class that will store the generic * arguments passed into the applications this will be derived @@ -95,11 +106,18 @@ public: /// @brief Stop this computer from sleeping bool m_preventSleep = false; + /// @brief Disable EI (Emulated Input) for Wayland support + bool m_disableWaylandEi = kDisableEiDefault; + + /// @brief Disable Portal for Wayland support (use EI sockets instead) + bool m_disableWaylandPortal = kDisablePortalDefault; + #if SYSAPI_WIN32 bool m_debugServiceWait = false; bool m_pauseOnExit = false; bool m_stopOnDeskSwitch = false; #endif + #if WINAPI_XWINDOWS bool m_disableXInitThreads = false; #endif @@ -112,5 +130,3 @@ protected: } }; } // namespace synergy - -#endif // SYNERGY_CORE_ARGSBASE_H diff --git a/src/lib/synergy/CMakeLists.txt b/src/lib/synergy/CMakeLists.txt index 29acc491c..3a0e59244 100644 --- a/src/lib/synergy/CMakeLists.txt +++ b/src/lib/synergy/CMakeLists.txt @@ -21,21 +21,12 @@ file(GLOB sources "*.cpp" "languages/*.cpp") if(WIN32) file(GLOB arch_headers "win32/*.h") file(GLOB arch_sources "win32/*.cpp") - include_directories("../../../ext/WinToast/src") - list(APPEND arch_sources "../../../ext/WinToast/src/wintoastlib.cpp") + list(APPEND arch_sources ${WINTOAST_DIR}/src/wintoastlib.cpp) elseif(UNIX) file(GLOB arch_headers "unix/*.h") file(GLOB arch_sources "unix/*.cpp") endif() -option(SYSTEM_PUGIXML "Use system pugixml instead of vendored one" OFF) -if(SYSTEM_PUGIXML) - find_package(pugixml REQUIRED) -else() - include_directories("../../../ext/pugixml/src") - list(APPEND arch_sources "../../../ext/pugixml/src/pugixml.cpp") -endif() - list(APPEND sources ${arch_sources}) list(APPEND headers ${arch_headers}) @@ -56,18 +47,18 @@ if(UNIX) platform mt server) + if(NOT APPLE) + target_link_libraries(synlib pugixml) + find_package(PkgConfig REQUIRED) pkg_check_modules(lib_glib REQUIRED IMPORTED_TARGET glib-2.0) pkg_search_module(PC_GDKPIXBUF gdk-pixbuf-2.0) include_directories(${PC_GDKPIXBUF_INCLUDE_DIRS}) + pkg_check_modules(lib_gdkpixbuf REQUIRED IMPORTED_TARGET gdk-pixbuf-2.0) pkg_check_modules(lib_notify REQUIRED IMPORTED_TARGET libnotify) target_link_libraries(synlib PkgConfig::lib_glib PkgConfig::lib_gdkpixbuf PkgConfig::lib_notify) endif() endif() - -if(SYSTEM_PUGIXML) - target_link_libraries(synlib pugixml) -endif() diff --git a/src/lib/synergy/ClientApp.cpp b/src/lib/synergy/ClientApp.cpp index a7d9ddf8e..00a3a135a 100644 --- a/src/lib/synergy/ClientApp.cpp +++ b/src/lib/synergy/ClientApp.cpp @@ -48,9 +48,14 @@ #if WINAPI_MSWINDOWS #include "platform/MSWindowsScreen.h" -#elif WINAPI_XWINDOWS +#endif +#if WINAPI_XWINDOWS #include "platform/XWindowsScreen.h" -#elif WINAPI_CARBON +#endif +#if WINAPI_LIBEI +#include "platform/EiScreen.h" +#endif +#if WINAPI_CARBON #include "platform/OSXScreen.h" #endif @@ -106,44 +111,53 @@ void ClientApp::parseArgs(int argc, const char *const *argv) { } void ClientApp::help() { -#if WINAPI_XWINDOWS -#define WINAPI_ARG " [--display ] [--no-xinitthreads]" -#define WINAPI_INFO \ - " --display connect to the X server at \n" \ - " --no-xinitthreads do not call XInitThreads()\n" -#else -#define WINAPI_ARG "" -#define WINAPI_INFO "" -#endif std::stringstream help; - help << "Usage: " << args().m_pname << " [--address
]" - << " [--yscroll ]" - << " [--sync-language]" - << " [--invert-scroll]" - << " [--host]" << WINAPI_ARG << HELP_SYS_ARGS << HELP_COMMON_ARGS - << " " - << "\n\n" - << "Connect to a synergy mouse/keyboard sharing server.\n" - << "\n" - << " -a, --address
local network interface address.\n" - << HELP_COMMON_INFO_1 << WINAPI_INFO << HELP_SYS_INFO - << " --yscroll defines the vertical scrolling delta, " - "which is\n" - << " 120 by default.\n" - << " --sync-language set this parameter to enable language " - "synchronization.\n" - << " --invert-scroll invert scroll direction on this " - "computer.\n" - << " --host client starts a listener and waits for a " - "server connection.\n" - << HELP_COMMON_INFO_2 << "\n" - << "* marks defaults.\n" - << "\n" - << "The server address is of the form: [][:]. The " - "hostname\n" - << "must be the address or hostname of the server. The port overrides " - "the\n" - << "default port, " << kDefaultPort << ".\n"; + help + << "Usage: " << args().m_pname << " [--address
]" + << " [--yscroll ]" + << " [--sync-language]" + << " [--invert-scroll]" + << " [--host]" +#ifdef WINAPI_XWINDOWS + << " [--display ]" + << " [--no-xinitthreads]" +#endif +#ifdef WINAPI_LIBEI + << " [--use-x-window]" +#endif + << HELP_SYS_ARGS << HELP_COMMON_ARGS << " " + << "\n\n" + << "Connect to a synergy mouse/keyboard sharing server.\n" + << "\n" + << " -a, --address
local network interface address.\n" + << HELP_COMMON_INFO_1 << HELP_SYS_INFO + << " --yscroll defines the vertical scrolling delta,\n" + << " which is 120 by default.\n" + << " --sync-language enable language synchronization.\n" + << " --invert-scroll invert scroll direction on this\n" + << " computer.\n" + << " --host act as a host; invert server/client mode\n" + << " and listen instead of connecting.\n" +#if WINAPI_XWINDOWS + << " --display connect to the X server at \n" + << " --no-xinitthreads do not call XInitThreads()\n" +#endif +#if defined(WINAPI_XWINDOWS) && defined(WINAPI_LIBEI) + << kNoWaylandEiArg +#endif +#if defined(WINAPI_LIBPORTAL) && defined(WINAPI_LIBEI) + << " --no-wayland-portal do not use Portal for Wayland and \n" + << " connect to EI socket instead.\n" +#endif + << HELP_COMMON_INFO_2 << "\n" + << "* marks defaults.\n" + + << kHelpNoWayland + + << "\n" + << "The server address is of the form: [][:].\n" + << "The hostname must be the address or hostname of the server.\n" + << "The port overrides the default port, " << kDefaultPort << ".\n"; LOG((CLOG_PRINT "%s", help.str().c_str())); } @@ -172,13 +186,23 @@ synergy::Screen *ClientApp::createScreen() { false, args().m_noHooks, args().m_stopOnDeskSwitch, m_events, args().m_enableLangSync, args().m_clientScrollDirection), m_events); -#elif WINAPI_XWINDOWS +#endif +#if WINAPI_LIBEI + if (!args().m_disableWaylandEi) { + return new synergy::Screen( + new synergy::EiScreen(false, m_events, !args().m_disableWaylandPortal), + m_events); + } +#endif +#if WINAPI_XWINDOWS return new synergy::Screen( new XWindowsScreen( args().m_display, false, args().m_disableXInitThreads, args().m_yscroll, m_events, args().m_clientScrollDirection), m_events); -#elif WINAPI_CARBON + +#endif +#if WINAPI_CARBON return new synergy::Screen( new OSXScreen( m_events, false, args().m_enableLangSync, diff --git a/src/lib/synergy/IPrimaryScreen.cpp b/src/lib/synergy/IPrimaryScreen.cpp index 82b756477..41dd5cb14 100644 --- a/src/lib/synergy/IPrimaryScreen.cpp +++ b/src/lib/synergy/IPrimaryScreen.cpp @@ -79,3 +79,13 @@ IPrimaryScreen::HotKeyInfo *IPrimaryScreen::HotKeyInfo::alloc(UInt32 id) { info->m_id = id; return info; } + +// +// IPrimaryScreen::EiConnectInfo +// + +IPrimaryScreen::EiConnectInfo *IPrimaryScreen::EiConnectInfo::alloc(int fd) { + EiConnectInfo *info = (EiConnectInfo *)malloc(sizeof(EiConnectInfo)); + info->m_fd = fd; + return info; +} diff --git a/src/lib/synergy/IPrimaryScreen.h b/src/lib/synergy/IPrimaryScreen.h index a1ac72710..f9bda9b7e 100644 --- a/src/lib/synergy/IPrimaryScreen.h +++ b/src/lib/synergy/IPrimaryScreen.h @@ -70,6 +70,14 @@ public: UInt32 m_id; }; + class EiConnectInfo { + public: + static EiConnectInfo *alloc(int fd); + + public: + int m_fd; + }; + //! @name manipulators //@{ diff --git a/src/lib/synergy/ServerApp.cpp b/src/lib/synergy/ServerApp.cpp index 760e60ced..d17c559e7 100644 --- a/src/lib/synergy/ServerApp.cpp +++ b/src/lib/synergy/ServerApp.cpp @@ -36,6 +36,7 @@ #include "server/ClientProxy.h" #include "server/PrimaryClient.h" #include "server/Server.h" +#include "synergy/App.h" #include "synergy/ArgParser.h" #include "synergy/Screen.h" #include "synergy/ServerArgs.h" @@ -48,9 +49,14 @@ #if WINAPI_MSWINDOWS #include "platform/MSWindowsScreen.h" -#elif WINAPI_XWINDOWS +#endif +#if WINAPI_XWINDOWS #include "platform/XWindowsScreen.h" -#elif WINAPI_CARBON +#endif +#if WINAPI_LIBEI +#include "platform/EiScreen.h" +#endif +#if WINAPI_CARBON #include "platform/OSXScreen.h" #endif @@ -60,6 +66,7 @@ #include #include +#include #include // @@ -104,46 +111,61 @@ void ServerApp::parseArgs(int argc, const char *const *argv) { } void ServerApp::help() { - // window api args (windows/x-windows/carbon) -#if WINAPI_XWINDOWS -#define WINAPI_ARGS " [--display ] [--no-xinitthreads]" -#define WINAPI_INFO \ - " --display connect to the X server at \n" \ - " --no-xinitthreads do not call XInitThreads()\n" -#else -#define WINAPI_ARGS -#define WINAPI_INFO -#endif - static const int buffer_size = 3000; - char buffer[buffer_size]; - snprintf( - buffer, buffer_size, - "Usage: %s" - " [--address
]" - " [--config ]" WINAPI_ARGS HELP_SYS_ARGS HELP_COMMON_ARGS "\n\n" - "Start the synergy mouse/keyboard sharing server.\n" - "\n" - " -a, --address
listen for clients on the given address.\n" - " -c, --config use the named configuration file " - "instead.\n" HELP_COMMON_INFO_1 WINAPI_INFO - HELP_SYS_INFO HELP_COMMON_INFO_2 "\n" - "* marks defaults.\n" - "\n" - "The argument for --address is of the form: [][:]. The\n" - "hostname must be the address or hostname of an interface on the " - "system.\n" - "The default is to listen on all interfaces. The port overrides the\n" - "default port, %d.\n" - "\n" - "If no configuration file pathname is provided then the first of the\n" - "following to load successfully sets the configuration:\n" - " %s\n" - " %s\n", - args().m_pname, kDefaultPort, - ARCH->concatPath(ARCH->getUserDirectory(), USR_CONFIG_NAME).c_str(), - ARCH->concatPath(ARCH->getSystemDirectory(), SYS_CONFIG_NAME).c_str()); + const auto userConfig = + ARCH->concatPath(ARCH->getUserDirectory(), USR_CONFIG_NAME); + const auto sysConfig = + ARCH->concatPath(ARCH->getSystemDirectory(), SYS_CONFIG_NAME); - LOG((CLOG_PRINT "%s", buffer)); + std::stringstream help; + help + << "Usage: " << args().m_pname + + << " [--address
]" + << " [--config ]" + +#if WINAPI_XWINDOWS + << " [--display ] [--no-xinitthreads]" +#endif + +#ifdef WINAPI_LIBEI + << " [--no-wayland-ei]" +#endif + + << HELP_SYS_ARGS HELP_COMMON_ARGS "\n\n" + << "Start the synergy mouse/keyboard sharing server.\n" + << "\n" + << " -a, --address
listen for clients on the given address.\n" + << " -c, --config use the named configuration file " + << "instead.\n" HELP_COMMON_INFO_1 + +#if WINAPI_XWINDOWS + << " --display connect to the X server at \n" + << " --no-xinitthreads do not call XInitThreads()\n" +#endif + +#if defined(WINAPI_XWINDOWS) && defined(WINAPI_LIBEI) + << kNoWaylandEiArg +#endif + + << HELP_SYS_INFO HELP_COMMON_INFO_2 "\n" + << "* marks defaults.\n" + + << kHelpNoWayland + + << "\n" + << "The argument for --address is of the form: [][:]. " + "The\n" + << "hostname must be the address or hostname of an interface on the " + << "system.\n" + << "The default is to listen on all interfaces. The port overrides the\n" + << "default port, " << kDefaultPort << ".\n" + << "\n" + << "If no configuration file pathname is provided then the first of the\n" + << "following to load successfully sets the configuration:\n" + << " " << userConfig << "\n" + << " " << sysConfig << "\n"; + + LOG((CLOG_PRINT "%s", help.str().c_str())); } void ServerApp::reloadSignalHandler(Arch::ESignal, void *) { @@ -527,7 +549,17 @@ synergy::Screen *ServerApp::createScreen() { new MSWindowsScreen( true, args().m_noHooks, args().m_stopOnDeskSwitch, m_events), m_events); -#elif WINAPI_XWINDOWS +#endif + +#if WINAPI_LIBEI + if (!args().m_disableWaylandEi) { + return new synergy::Screen( + new synergy::EiScreen(true, m_events, !args().m_disableWaylandPortal), + m_events); + } +#endif + +#if WINAPI_XWINDOWS return new synergy::Screen( new XWindowsScreen( args().m_display, true, args().m_disableXInitThreads, 0, m_events), diff --git a/src/lib/synergy/mouse_types.h b/src/lib/synergy/mouse_types.h index bb509bb42..86b095ec8 100644 --- a/src/lib/synergy/mouse_types.h +++ b/src/lib/synergy/mouse_types.h @@ -33,6 +33,7 @@ static const ButtonID kButtonLeft = 1; static const ButtonID kButtonMiddle = 2; static const ButtonID kButtonRight = 3; static const ButtonID kButtonExtra0 = 4; +static const ButtonID kButtonExtra1 = 5; static const ButtonID kMacButtonRight = 2; static const ButtonID kMacButtonMiddle = 3; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 40e012f62..8236ac4cb 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -19,8 +19,6 @@ macro(config_all_tests) set(base_dir ${CMAKE_SOURCE_DIR}) set(src_dir ${base_dir}/src) set(test_base_dir ${src_dir}/test) - set(ext_dir ${base_dir}/ext) - set(gtest_base_dir ${ext_dir}/googletest) set(gui_dir ${src_dir}/gui/src) config_test_deps() @@ -126,25 +124,10 @@ endmacro() macro(config_test_deps) - set(gtest_dir ${gtest_base_dir}/googletest) - set(gmock_dir ${gtest_base_dir}/googlemock) - - include_directories(${gtest_dir} ${gmock_dir} ${gtest_dir}/include - ${gmock_dir}/include) - # gui library autogen headers: # qt doesn't seem to auto include the autogen headers for libraries. include_directories(${CMAKE_BINARY_DIR}/src/lib/gui/gui_autogen/include) - add_library(gtest STATIC ${gtest_dir}/src/gtest-all.cc) - add_library(gmock STATIC ${gmock_dir}/src/gmock-all.cc) - - if(UNIX) - # ignore warnings in gtest and gmock - set_target_properties(gtest PROPERTIES COMPILE_FLAGS "-w") - set_target_properties(gmock PROPERTIES COMPILE_FLAGS "-w") - endif() - set(test_libs gtest gmock @@ -161,6 +144,7 @@ macro(config_test_deps) ipc license gui + ${GTEST_LIBS} ${libs}) endmacro() diff --git a/src/test/mock/synergy/MockEventQueue.h b/src/test/mock/synergy/MockEventQueue.h index 826016547..4f9bccc22 100644 --- a/src/test/mock/synergy/MockEventQueue.h +++ b/src/test/mock/synergy/MockEventQueue.h @@ -67,5 +67,6 @@ public: MOCK_METHOD(IScreenEvents &, forIScreen, (), (override)); MOCK_METHOD(ClipboardEvents &, forClipboard, (), (override)); MOCK_METHOD(FileEvents &, forFile, (), (override)); + MOCK_METHOD(EiEvents &, forEi, (), (override)); MOCK_METHOD(void, waitForReady, (), (const, override)); }; diff --git a/subprojects/.gitignore b/subprojects/.gitignore new file mode 100644 index 000000000..62fb4fc35 --- /dev/null +++ b/subprojects/.gitignore @@ -0,0 +1,12 @@ +# Fetched dependencies. +/packagecache +/googletest-* +/WinToast-* +/libei +/libportal +/gi-docgen +/munit + +# Added by dependencies. +/gi-docgen.wrap +/munit.wrap diff --git a/subprojects/gtest.wrap b/subprojects/gtest.wrap new file mode 100644 index 000000000..91afad57e --- /dev/null +++ b/subprojects/gtest.wrap @@ -0,0 +1,16 @@ +[wrap-file] +directory = googletest-1.15.0 +source_url = https://github.com/google/googletest/archive/refs/tags/v1.15.0.tar.gz +source_filename = gtest-1.15.0.tar.gz +source_hash = 7315acb6bf10e99f332c8a43f00d5fbb1ee6ca48c52f6b936991b216c586aaad +patch_filename = gtest_1.15.0-1_patch.zip +patch_url = https://wrapdb.mesonbuild.com/v2/gtest_1.15.0-1/get_patch +patch_hash = 5f8e484c48fdc1029c7fd08807bd2615f8c9d16f90df6d81984f4f292752c925 +source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/gtest_1.15.0-1/gtest-1.15.0.tar.gz +wrapdb_version = 1.15.0-1 + +[provide] +gtest = gtest_dep +gtest_main = gtest_main_dep +gmock = gmock_dep +gmock_main = gmock_main_dep diff --git a/subprojects/libei.wrap b/subprojects/libei.wrap new file mode 100644 index 000000000..83a5db463 --- /dev/null +++ b/subprojects/libei.wrap @@ -0,0 +1,3 @@ +[wrap-git] +url = https://gitlab.freedesktop.org/libinput/libei.git +revision = tags/1.3.0 diff --git a/subprojects/libportal.wrap b/subprojects/libportal.wrap new file mode 100644 index 000000000..d0fb1f7e6 --- /dev/null +++ b/subprojects/libportal.wrap @@ -0,0 +1,3 @@ +[wrap-git] +url = https://github.com/flatpak/libportal.git +revision = a1530a9 diff --git a/subprojects/packagefiles/wintoast-patch/meson.build b/subprojects/packagefiles/wintoast-patch/meson.build new file mode 100644 index 000000000..2efe5c3dd --- /dev/null +++ b/subprojects/packagefiles/wintoast-patch/meson.build @@ -0,0 +1,5 @@ +project('wintoast', 'cpp', version : '1.3.0') + +wintoast_dep = declare_dependency( + include_directories : include_directories('.') +) diff --git a/subprojects/wintoast.wrap b/subprojects/wintoast.wrap new file mode 100644 index 000000000..e7a4a6965 --- /dev/null +++ b/subprojects/wintoast.wrap @@ -0,0 +1,6 @@ +[wrap-file] +directory = WinToast-1.3.0 +source_url = https://github.com/mohabouje/WinToast/archive/refs/tags/v1.3.0.tar.gz +source_filename = wintoast-1.3.0.tar.gz +source_hash = 998bd82fb2f49ee4b0df98774424d72c2bc18225188f251a9242af28bb80e6d4 +patch_directory = wintoast-patch