diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c1682cd..a7cde7f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -32,14 +32,14 @@ variables: # See the documentation here: # # https://wayland.freedesktop.org/libinput/doc/latest/building_libinput.html # ############################################################################### - FEDORA_PACKAGES: 'git diffutils gcc gcc-c++ pkgconf-pkg-config meson systemd-devel protobuf-c-devel libxkbcommon-devel doxygen' + FEDORA_PACKAGES: 'git diffutils gcc gcc-c++ pkgconf-pkg-config meson systemd-devel protobuf-c-devel libxkbcommon-devel doxygen python3-attrs python3-pytest python3-dbusmock' ############################ end of package lists ############################# # these tags should be updated each time the list of packages is updated # changing these will force rebuilding the associated image # Note: these tags have no meaning and are not tied to a particular # libinput version - FEDORA_TAG: '2022-12-05.0' + FEDORA_TAG: '2022-12-05.1' FDO_UPSTREAM_REPO: libinput/libei diff --git a/.gitlab-ci/ci.template b/.gitlab-ci/ci.template index ae3fb8a..a55a00b 100644 --- a/.gitlab-ci/ci.template +++ b/.gitlab-ci/ci.template @@ -38,7 +38,7 @@ variables: # See the documentation here: # # https://wayland.freedesktop.org/libinput/doc/latest/building_libinput.html # ############################################################################### - FEDORA_PACKAGES: 'git diffutils gcc gcc-c++ pkgconf-pkg-config meson systemd-devel protobuf-c-devel libxkbcommon-devel doxygen' + FEDORA_PACKAGES: 'git diffutils gcc gcc-c++ pkgconf-pkg-config meson systemd-devel protobuf-c-devel libxkbcommon-devel doxygen python3-attrs python3-pytest python3-dbusmock' ############################ end of package lists ############################# # these tags should be updated each time the list of packages is updated diff --git a/.gitlab-ci/config.yml b/.gitlab-ci/config.yml index 20e9c22..c4a0183 100644 --- a/.gitlab-ci/config.yml +++ b/.gitlab-ci/config.yml @@ -3,7 +3,7 @@ # # We're happy to rebuild all containers when one changes. -.default_tag: &default_tag '2022-12-05.0' +.default_tag: &default_tag '2022-12-05.1' distributions: - name: fedora diff --git a/README.md b/README.md index 066b37c..a39340a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ libei ===== **libei** is a library for Emulated Input, primarily aimed at the Wayland -stack. It provides three parts: +stack. It provides four parts: - 🥚 EI (Emulated Input) for the client side (`libei`) - 🍦 EIS (Emulated Input Server) for the server side (`libeis`) - 🍚 REIS (Restrictions for the EIS) for the portal in between (`libreis`) +- 🚌 oeffis is an optional helper library for DBus communication with the + XDG RemoteDesktop portal (`liboeffis`) The communication between these parts is an implementation detail, neither client nor server need to care about the details. Let's call it the BRidge @@ -370,3 +372,13 @@ to verify which client is allowed to emulate input devices at the kernel level (polkit? config files?). This however is out of scope for **libei**. An example uinput server is implemented in the `eis-server-demo` in the with libei repository. + +# liboeffis + +In a Wayland and/or sandboxed environment, emulating input events requires +going through the XDG RemoteDesktop portal. This portal is available on DBus +but communication with DBus is often cumbersome, especially for small tools. + +`liboeffis` is an optional library that provides the communication with the +portal, sufficient to start a RemoteDesktop session and retrieve the file +descriptor to the EIS implementation. diff --git a/doc/meson.build b/doc/meson.build index 8990a84..016484d 100644 --- a/doc/meson.build +++ b/doc/meson.build @@ -13,6 +13,7 @@ src_doxygen = files( # source files '../src/libei.h', '../src/libeis.h', + '../src/liboeffis.h', # style files 'doxygen-awesome.css', ) diff --git a/meson.build b/meson.build index ec53ed9..9a90c5a 100644 --- a/meson.build +++ b/meson.build @@ -2,7 +2,7 @@ project('libei', 'c', version: '0.3', license: 'MIT', default_options: [ 'c_std=gnu99', 'warning_level=2' ], - meson_version: '>= 0.50.0') + meson_version: '>= 0.57.0') pkgconfig = import('pkgconfig') @@ -224,6 +224,36 @@ executable('ei-debug-events', dependencies: [dep_libutil, dep_libei, dep_libevdev], install: true) +dep_systemd = dependency('libsystemd', required: get_option('liboeffis')) +build_oeffis = dep_systemd.found() +if build_oeffis + src_liboeffis = 'src/liboeffis.c' + deps_liboeffis = [dep_libutil, dep_systemd] + + lib_liboeffis = shared_library('oeffis', + src_liboeffis, + dependencies: deps_liboeffis, + gnu_symbol_visibility: 'hidden', + install: true + ) + install_headers('src/liboeffis.h') + + dep_liboeffis = declare_dependency(link_with: lib_liboeffis, + include_directories: 'src') + + pkgconfig.generate(lib_liboeffis, + filebase: 'liboeffis', + name: 'libOeffis', + description: 'RemoteDesktop portal DBus helper library', + version: meson.project_version(), + libraries: lib_liboeffis, + ) + + executable('oeffis-demo-tool', + 'tools/oeffis-demo-tool.c', + dependencies: [dep_libutil, dep_liboeffis]) +endif + # tests if get_option('tests') subproject('munit', default_options: 'werror=false') @@ -265,6 +295,28 @@ if get_option('tests') c_args: ['-D_enable_tests_'], dependencies: [dep_unittest, dep_libutil, dep_protobuf])) + if build_oeffis + test('unit-tests-oeffis', + executable('unit-tests-oeffis', + 'test/unit-tests.c', + src_liboeffis, + include_directories: 'src', + c_args: ['-D_enable_tests_'], + dependencies: deps_liboeffis + [dep_unittest])) + + env = environment() + env.set('LD_LIBRARY_PATH', meson.current_build_dir()) + pymod = import('python') + pymod.find_installation('python3', modules: ['pytest', 'attr', 'dbusmock']) + pytest = find_program('pytest-3', 'pytest') + test('pytest', pytest, + args: ['--verbose', '--log-level=DEBUG'], + suite: 'python', + workdir: meson.current_source_dir() / 'test', + env: env, + ) + endif + lib_eierpecken = static_library('eierpecken', 'test/eierpecken.h', 'test/eierpecken.c', @@ -290,6 +342,7 @@ if get_option('tests') '--leak-check=full', '--gen-suppressions=all', '--error-exitcode=3' ], + exclude_suites: ['python'], # we don't want to valgrind python tests timeout_multiplier : 100) else message('valgrind not found, disabling valgrind test suite') diff --git a/meson_options.txt b/meson_options.txt index a2862fc..92d3d57 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -1,2 +1,3 @@ option('documentation', type: 'boolean', value: 'false', description: 'Enable/disable building the API documentation') option('tests', type: 'boolean', value: 'true', description: 'Enable/disable tests') +option('liboeffis', type: 'feature', value: 'auto', description: 'Build liboeffis.so') diff --git a/src/liboeffis.c b/src/liboeffis.c new file mode 100644 index 0000000..a2765c0 --- /dev/null +++ b/src/liboeffis.c @@ -0,0 +1,753 @@ +/* SPDX-License-Identifier: MIT */ +/* + * Copyright © 2022 Red Hat, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +#include "config.h" + +#include + +#include "util-io.h" +#include "util-macros.h" +#include "util-object.h" +#include "util-sources.h" +#include "util-strings.h" +#include "util-time.h" +#include "util-version.h" + +#include "liboeffis.h" + +_Static_assert(sizeof(enum oeffis_event_type) == sizeof(int), "Invalid enum size"); +_Static_assert(sizeof(enum oeffis_device) == sizeof(uint32_t), "Invalid enum size"); + +/* oeffis is simple enough that we don't really need debugging or a log + * handler. If you want this on, just define it */ +/* #define log_debug(...) fprintf(stderr, "DEBUG " __VA_ARGS__) */ +#define log_debug(...) /* */ + +static void +portal_init(struct oeffis *oeffis, const char *busname); +static void +portal_start(struct oeffis *oeffis); + +enum oeffis_state { + OEFFIS_STATE_NEW, + OEFFIS_STATE_CREATE_SESSION, + OEFFIS_STATE_SESSION_CREATED, + OEFFIS_STATE_STARTED, + OEFFIS_STATE_CONNECTED_TO_EIS, + OEFFIS_STATE_DISCONNECTED, /* used for closed as well since + internally it's the same thing */ +}; + +struct oeffis { + struct object object; + void *user_data; + struct sink *sink; + + enum oeffis_state state; + uint32_t devices; + + /* We can have a maximum of 3 events (connected, optional closed, + * disconnected) so we can have the event queue be a fixed + * null-terminated array, have a pointer to the current element and + * shift that on with every event */ + enum oeffis_event_type event_queue[4]; + /* Points to the next client-visible event in the event queue. only + * advanced by the client */ + enum oeffis_event_type *next_event; + + int eis_fd; + + /* NULL until OEFFIS_STATE_DISCONNECTED */ + char *error_message; + + /* internal epollfd tickler */ + struct source *epoll_tickler_source; + int pipefd[2]; + + /* sd-bus pieces */ + struct source *bus_source; + sd_bus *bus; + sd_bus_slot *slot_request_response; /* Re-used for Request.Response */ + sd_bus_slot *slot_session_closed; + char *busname; + char *session_path; + char *sender_name; +}; + +static void +oeffis_destroy(struct oeffis *oeffis) +{ + free(oeffis->error_message); + sink_unref(oeffis->sink); + xclose(oeffis->eis_fd); + xclose(oeffis->pipefd[0]); + xclose(oeffis->pipefd[1]); + + free(oeffis->sender_name); + free(oeffis->session_path); + free(oeffis->busname); + sd_bus_close_unref(oeffis->bus); + sd_bus_slot_unref(oeffis->slot_request_response); + sd_bus_slot_unref(oeffis->slot_session_closed); +} + +static +OBJECT_IMPLEMENT_CREATE(oeffis); +_public_ +OBJECT_IMPLEMENT_REF(oeffis); +_public_ +OBJECT_IMPLEMENT_UNREF_CLEANUP(oeffis); +_public_ +OBJECT_IMPLEMENT_SETTER(oeffis, user_data, void *); +_public_ +OBJECT_IMPLEMENT_GETTER(oeffis, user_data, void *); +_public_ +OBJECT_IMPLEMENT_GETTER(oeffis, error_message, const char *); + +DEFINE_UNREF_CLEANUP_FUNC(source); + +static void +tickle(struct oeffis *oeffis) +{ + write(oeffis->pipefd[1], "kitzel", 6); +} + +static void +_printf_(2, 3) +oeffis_disconnect(struct oeffis *oeffis, const char *fmt, ...) +{ + if (oeffis->state == OEFFIS_STATE_DISCONNECTED) + return; + + va_list args; + va_start(args, fmt); + oeffis->state = OEFFIS_STATE_DISCONNECTED; + oeffis->error_message = xvasprintf(fmt, args); + va_end(args); + + *oeffis->next_event = OEFFIS_EVENT_DISCONNECTED; + + oeffis->eis_fd = xclose(oeffis->eis_fd); + + tickle(oeffis); + + /* FIXME: need to so more here? */ +} + +static void +tickled(struct source *source, void *data) +{ + /* Nothing to do here, just drain the data */ + char buf[64]; + read(source_get_fd(source), buf, sizeof(buf)); +} + +_public_ struct oeffis * +oeffis_new(void *user_data) +{ + _unref_(oeffis) *oeffis = oeffis_create(NULL); + + oeffis->state = OEFFIS_STATE_NEW; + oeffis->user_data = user_data; + oeffis->next_event = oeffis->event_queue; + oeffis->eis_fd = -1; + oeffis->pipefd[0] = -1; + oeffis->pipefd[1] = -1; + + oeffis->sink = sink_new(); + if (!oeffis->sink) + return NULL; + + /* set up a pipe we can write to to force the epoll to wake up even when + * nothing else happens */ + int rc = pipe2(oeffis->pipefd, O_CLOEXEC|O_NONBLOCK); + if (rc < 0) + return NULL; + + _unref_(source) *s = source_new(oeffis->pipefd[0], tickled, NULL); + sink_add_source(oeffis->sink, s); + + return steal(&oeffis); +} + +_public_ int +oeffis_get_fd(struct oeffis *oeffis) +{ + return sink_get_fd(oeffis->sink); +} + +_public_ int +oeffis_get_eis_fd(struct oeffis *oeffis) +{ + if (oeffis->state != OEFFIS_STATE_CONNECTED_TO_EIS) { + errno = ENODEV; + return -1; + } + + return dup(oeffis->eis_fd); +} + +_public_ enum oeffis_event_type +oeffis_get_event(struct oeffis *oeffis) +{ + enum oeffis_event_type e = *oeffis->next_event; + + if (e != OEFFIS_EVENT_NONE) + oeffis->next_event++; + + assert(oeffis->next_event < oeffis->event_queue + ARRAY_LENGTH(oeffis->event_queue)); + + return e; +} + +_public_ void +oeffis_create_session(struct oeffis *oeffis, uint32_t devices) +{ + oeffis_create_session_on_bus(oeffis, "org.freedesktop.portal.Desktop", devices); +} + +_public_ void +oeffis_create_session_on_bus(struct oeffis *oeffis, const char *busname, uint32_t devices) +{ + if (oeffis->state != OEFFIS_STATE_NEW) + return; + + oeffis->devices = devices; + oeffis->state = OEFFIS_STATE_CREATE_SESSION; + portal_init(oeffis, busname); +} + +_public_ void +oeffis_dispatch(struct oeffis *oeffis) +{ + sink_dispatch(oeffis->sink); +} + +static int +oeffis_set_eis_fd(struct oeffis *oeffis, int eisfd) +{ + if (oeffis->state != OEFFIS_STATE_STARTED) + return -EALREADY; + + oeffis->state = OEFFIS_STATE_CONNECTED_TO_EIS; + oeffis->eis_fd = eisfd; + *oeffis->next_event = OEFFIS_EVENT_CONNECTED_TO_EIS; + + tickle(oeffis); + + return 0; +} + +static void +oeffis_close(struct oeffis *oeffis) +{ + switch (oeffis->state) { + case OEFFIS_STATE_NEW: + oeffis_disconnect(oeffis, "Bug: Received Session.Close in state NEW."); + break; + case OEFFIS_STATE_CREATE_SESSION: + case OEFFIS_STATE_SESSION_CREATED: + case OEFFIS_STATE_CONNECTED_TO_EIS: + case OEFFIS_STATE_STARTED: + *oeffis->next_event = OEFFIS_EVENT_CLOSED; + tickle(oeffis); + oeffis->state = OEFFIS_STATE_DISCONNECTED; + break; + case OEFFIS_STATE_DISCONNECTED: + break; + } +} + +/********************************************** DBus implementation **************************************************/ + +static char * +sender_name(sd_bus *bus) +{ + _cleanup_free_ char *sender = NULL; + const char *name = NULL; + + if (sd_bus_get_unique_name(bus, &name) != 0) + return NULL; + + sender = xstrdup(name + 1); /* drop initial : */ + + for (unsigned i = 0; sender[i]; i++) { + if (sender[i] == '.') + sender[i] = '_'; + } + + return steal(&sender); +} +static char * +xdp_token(void) +{ + /* next for easier debugging, rand() so we don't ever conflict in + * real life situations */ + static uint32_t next = 0; + return xaprintf("oeffis_%u_%d", next++, rand()); +} + +static char * +xdp_request_path(char *sender_name, char *token) +{ + return xaprintf("/org/freedesktop/portal/desktop/request/%s/%s", sender_name, token); +} + +static char * +xdp_session_path(char *sender_name, char *token) +{ + return xaprintf("/org/freedesktop/portal/desktop/session/%s/%s", sender_name, token); +} + +static void +portal_connect_to_eis(struct oeffis *oeffis) +{ + _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; + _unref_(sd_bus_message) *response = NULL; + sd_bus *bus = oeffis->bus; + int eisfd; + + int rc = sd_bus_call_method(bus, oeffis->busname, + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.RemoteDesktop", + "ConnectToEIS", + &error, + &response, + "oa{sv}", + oeffis->session_path, + 0); + + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to call ConnectToEIS: %s", strerror(-rc)); + return; + } + + rc = sd_bus_message_read(response, "h", &eisfd); + if (rc < 0) { + oeffis_disconnect(oeffis, "Unable to get fd from portal: %s", strerror(-rc)); + return; + } + + /* the fd is owned by the message */ + rc = xerrno(dup(eisfd)); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to dup fd: %s", strerror(-rc)); + return; + } else { + eisfd = rc; + int flags = fcntl(eisfd, F_GETFL, 0); + fcntl(eisfd, F_SETFL, flags | O_NONBLOCK); + } + + log_debug("Got fd %d from portal\n", eisfd); + + rc = oeffis_set_eis_fd(oeffis, eisfd); + if (rc < 0) + oeffis_disconnect(oeffis, "Failed to set the fd: %s", strerror(-rc)); +} + +static int +session_closed_received(sd_bus_message *m, void *userdata, sd_bus_error *error) +{ + struct oeffis *oeffis = userdata; + + oeffis_close(oeffis); + + return 0; +} + +static void +dbus_dispatch(struct source *source, void *data) +{ + struct oeffis *oeffis = data; + sd_bus *bus = oeffis->bus; + + int rc; + do { + rc = sd_bus_process(bus, NULL); + } while (rc > 0); + + if (rc < 0) + oeffis_disconnect(oeffis, "dbus processing failed with %s", strerror(-rc)); +} + +static int +portal_setup_request(struct oeffis *oeffis, sd_bus_message_handler_t response_handler, + char **token_return, sd_bus_slot **slot_return) +{ + sd_bus *bus = oeffis->bus; + _unref_(sd_bus_slot) *slot = NULL; + _cleanup_free_ char *token = xdp_token(); + _cleanup_free_ char *handle = xdp_request_path(oeffis->sender_name, token); + + int rc = sd_bus_match_signal(bus, &slot, + oeffis->busname, + handle, + "org.freedesktop.portal.Request", + "Response", + response_handler, + oeffis); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to subscribe to Request.Response signal: %s", strerror(-rc)); + return rc; + } + + *token_return = steal(&token); + *slot_return = steal(&slot); + + return 0; +} + +static int +portal_start_response_received(sd_bus_message *m, void *userdata, sd_bus_error *error) +{ + struct oeffis *oeffis = userdata; + + /* We'll only get this signal once */ + oeffis->slot_request_response = sd_bus_slot_unref(oeffis->slot_request_response); + + unsigned int response; + int rc = sd_bus_message_read(m, "u", &response); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to read response from signal: %s", strerror(-rc)); + return 0; + } + + log_debug("Portal Start reponse is %u\n", response); + if (response != 0) { + oeffis_disconnect(oeffis, "Portal denied Start"); + return 0; + } + + oeffis->state = OEFFIS_STATE_STARTED; + + /* Response includes the the device bitmask but we don't care about this here */ + + /* Don't need a separate state here, ConnectToEIS is synchronous */ + portal_connect_to_eis(oeffis); + + return 0; +} + +static void +portal_start(struct oeffis *oeffis) +{ + _cleanup_free_ char *token = NULL; + _unref_(sd_bus_slot) *request_slot = NULL; + + int rc = portal_setup_request(oeffis, portal_start_response_received, &token, &request_slot); + if (rc < 0) + return; + + _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; + _unref_(sd_bus_message) *response = NULL; + sd_bus *bus = oeffis->bus; + rc = sd_bus_call_method(bus, + oeffis->busname, + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.RemoteDesktop", + "Start", + &error, + &response, + "osa{sv}", + oeffis->session_path, + "", /* parent window */ + 1, + "handle_token", /* string key */ + "s", token /* variant string */ + ); + + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to call method: %s", strerror(-rc)); + return; + } + + const char *path = NULL; + rc = sd_bus_message_read(response, "o", &path); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to parse Start reply: %s", strerror(-rc)); + return; + } + + oeffis->slot_request_response = sd_bus_slot_ref(request_slot); + return; +} + +static int +portal_select_devices_response_received(sd_bus_message *m, void *userdata, sd_bus_error *error) +{ + struct oeffis *oeffis = userdata; + + /* We'll only get this signal once */ + oeffis->slot_request_response = sd_bus_slot_unref(oeffis->slot_request_response); + + unsigned int response; + int rc = sd_bus_message_read(m, "u", &response); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to read response from signal: %s", strerror(-rc)); + return 0; + } + + log_debug("Portal SelectDevices reponse is %u\n", response); + if (response != 0) { + oeffis_disconnect(oeffis, "Portal denied SelectDevices"); + return 0; + } + + /* Response includes the the device bitmask but we don't care about this here */ + portal_start(oeffis); + + return 0; +} + +static void +portal_select_devices(struct oeffis *oeffis) +{ + sd_bus *bus = oeffis->bus; + + _cleanup_free_ char *token = NULL; + _unref_(sd_bus_slot) *request_slot = NULL; + int rc = portal_setup_request(oeffis, portal_select_devices_response_received, &token, &request_slot); + if (rc < 0) + return; + + _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; + _unref_(sd_bus_message) *response = NULL; + rc = sd_bus_call_method(bus, + oeffis->busname, + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.RemoteDesktop", + "SelectDevices", + &error, + &response, + "oa{sv}", + oeffis->session_path, + 2, + "handle_token", /* string key */ + "s", token, /* variant string */ + "types", /* string key */ + "u", oeffis->devices + ); + + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to call method: %s", strerror(-rc)); + return; + } + + const char *path = NULL; + rc = sd_bus_message_read(response, "o", &path); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to parse Start reply: %s", strerror(-rc)); + return; + } + + oeffis->slot_request_response = sd_bus_slot_ref(request_slot); +} + +static int +portal_create_session_response_received(sd_bus_message *m, void *userdata, sd_bus_error *error) +{ + struct oeffis *oeffis = userdata; + + /* We'll only get this signal once */ + oeffis->slot_request_response = sd_bus_slot_unref(oeffis->slot_request_response); + + unsigned int response; + int rc = sd_bus_message_read(m, "u", &response); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to read response from signal: %s", strerror(-rc)); + return 0; + } + + log_debug("Portal CreateSession reponse is %u\n", response); + + const char *session_handle = NULL; + if (response == 0) { + const char *key; + rc = sd_bus_message_read(m, "a{sv}", 1, &key, "s", &session_handle); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to read session handle from signal: %s", strerror(-rc)); + return 0; + } + + if (!streq(key, "session_handle")) { + oeffis_disconnect(oeffis, "Invalid or unhandled option: %s", key); + return 0; + } + } + + if (response != 0) { + oeffis_disconnect(oeffis, "Portal denied CreateSession"); + return 0; + } + + oeffis->session_path = xstrdup(session_handle); + oeffis->state = OEFFIS_STATE_SESSION_CREATED; + + portal_select_devices(oeffis); + + return 0; +} + +static void +portal_init(struct oeffis *oeffis, const char *busname) +{ + _cleanup_(sd_bus_error_free) sd_bus_error error = SD_BUS_ERROR_NULL; + _unref_(sd_bus) *bus = NULL; + _unref_(sd_bus_message) *response = NULL; + const char *path = NULL; + + int rc = sd_bus_open_user(&bus); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to init dbus: %s", strerror(-rc)); + return; + } + + oeffis->sender_name = sender_name(bus); + if (!oeffis->sender_name) { + oeffis_disconnect(oeffis, "Failed to parse sender name"); + return; + } + + oeffis->bus = sd_bus_ref(bus); + oeffis->busname = xstrdup(busname); + + uint32_t version; + rc = sd_bus_get_property_trivial(bus, busname, + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.RemoteDesktop", + "version", + &error, + 'u', + &version); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to get RemoteDesktop.version: %s", strerror(sd_bus_error_get_errno(&error))); + return; + } else if (version < VERSION_V(2)) { + oeffis_disconnect(oeffis, "RemoteDesktop.version is %u, we need 2", version); + return; + } + log_debug("RemoteDesktop.version is %u\n", version); + + _cleanup_free_ char *token = NULL; + _unref_(sd_bus_slot) *request_slot = NULL; + rc = portal_setup_request(oeffis, portal_create_session_response_received, &token, &request_slot); + if (rc < 0) + return; + + _unref_(sd_bus_slot) *session_slot = NULL; + _cleanup_free_ char *session_token = xdp_token(); + _cleanup_free_ char *session_handle = xdp_session_path(oeffis->sender_name, session_token); + rc = sd_bus_match_signal(bus, &session_slot, + busname, + session_handle, + "org.freedesktop.portal.Session", + "Closed", + session_closed_received, + oeffis); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to subscribe to Session.Closed signal: %s", strerror(-rc)); + return; + } + + rc = sd_bus_call_method(bus, + busname, + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.RemoteDesktop", + "CreateSession", + &error, + &response, + "a{sv}", 2, + "handle_token", /* string key */ + "s", token, /* variant string */ + "session_handle_token", /* string key */ + "s", session_token /* variant string */ + ); + + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to call method: %s", strerror(-rc)); + return; + } + + rc = sd_bus_message_read(response, "o", &path); + if (rc < 0) { + oeffis_disconnect(oeffis, "Failed to parse CreateSession reply: %s", strerror(-rc)); + return; + } + + log_debug("Portal Response object is %s\n", path); + + _unref_(source) *s = source_new(sd_bus_get_fd(bus), dbus_dispatch, oeffis); + source_never_close_fd(s); /* the bus object handles the fd */ + rc = sink_add_source(oeffis->sink, s); + if (rc == 0) { + oeffis->bus_source = source_ref(s); + oeffis->slot_request_response = sd_bus_slot_ref(request_slot); + oeffis->slot_session_closed = sd_bus_slot_ref(session_slot); + } + + return; +} + +#ifdef _enable_tests_ +#include "util-munit.h" + +MUNIT_TEST(test_init_unref) +{ + struct oeffis *oeffis = oeffis_new(NULL); + + munit_assert_int(oeffis->state, ==, OEFFIS_STATE_NEW); + munit_assert_not_null(oeffis->sink); + munit_assert_int(oeffis->eis_fd, ==, -1); + + struct oeffis *refd = oeffis_ref(oeffis); + munit_assert_ptr_equal(oeffis, refd); + munit_assert_int(oeffis->object.refcount, ==, 2); + + struct oeffis *unrefd = oeffis_unref(oeffis); + munit_assert_null(unrefd); + + unrefd = oeffis_unref(oeffis); + munit_assert_null(unrefd); + + return MUNIT_OK; +} + +MUNIT_TEST(test_failed_connect) +{ + struct oeffis *oeffis = oeffis_new(NULL); + enum oeffis_event_type event; + + oeffis_create_session_on_bus(oeffis, "foo.bar.Example", 0); + while ((event = oeffis_get_event(oeffis)) == OEFFIS_EVENT_NONE) + oeffis_dispatch(oeffis); + munit_assert_int(event, ==, OEFFIS_EVENT_DISCONNECTED); + munit_assert_int(oeffis->state, ==, OEFFIS_STATE_DISCONNECTED); + munit_assert_not_null(oeffis->error_message); + + oeffis_unref(oeffis); + + return MUNIT_OK; +} +#endif diff --git a/src/liboeffis.h b/src/liboeffis.h new file mode 100644 index 0000000..d4996cf --- /dev/null +++ b/src/liboeffis.h @@ -0,0 +1,239 @@ +/* SPDX-License-Identifier: MIT */ +/* + * Copyright © 2022 Red Hat, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +/** + * @addtogroup oeffis liboeffis - An XDG RemoteDesktop portal wrapper API + * + * liboeffis is a helper library for applications that do not want to or cannot + * interact with the XDG RemoteDesktop DBus portal directly. + * + * liboeffis will: + * - connect to the DBus session bus and the ``org.freedesktop.portal.Desktop`` bus name + * - Start a ``org.freedesktop.portal.RemoteDesktop`` session, select the devices and invoke + * ``RemoteDesktop.ConnectToEIS()`` + * - Close everything in case of error or disconnection + * + * liboeffis is intentionally kept simple, any more complex needs should be + * handled by an application talking to DBus directly. + * + * @{ + */ + +/** + * @struct oeffis + * + * The main context to interact with liboeffis. A liboeffis context is a single + * connection to DBus session bus and must only be used once. + * + * @note A caller must keep the oeffis context alive after a successful + * connection. Destroying the context results in the DBus connection being + * closed and that again results in our EIS fd being invalidated by the + * portal and/or the EIS implementation. + * + * An @ref oeffis context is refcounted, see oeffis_unref(). + */ +struct oeffis; + +/** + * Create a new oeffis context. The context is refcounted and must be + * released with oeffis_unref(). + */ +struct oeffis * +oeffis_new(void *user_data); + +/** + * Increase the refcount of this struct by one. Use oeffis_unref() to decrease + * the refcount. + * + * @return the argument passed into the function + */ +struct oeffis * +oeffis_ref(struct oeffis *oeffis); + +/** + * Decrease the refcount of this struct by one. When the refcount reaches + * zero, the context disconnects from DBus and all allocated resources + * are released. + * + * @note A caller must keep the oeffis context alive after a successful + * connection. Destroying the context results in the DBus connection being + * closed and that again results in our EIS fd being invalidated by the + * portal and/or the EIS implementation. + * + * @return always NULL + */ +struct oeffis * +oeffis_unref(struct oeffis *oeffis); + +/** + * Set a custom data pointer for this context. liboeffis will not look at or + * modify the pointer. Use oeffis_get_user_data() to retrieve a previously set + * user data. + */ +void +oeffis_set_user_data(struct oeffis *oeffis, void *user_data); + +/** + * Return the custom data pointer for this context. liboeffis will not look at or + * modify the pointer. Use oeffis_set_user_data() to change the user data. + */ +void * +oeffis_get_user_data(struct oeffis *oeffis); + +/** + * liboeffis keeps a single file descriptor for all events. This fd should be + * monitored for events by the caller's mainloop, e.g. using select(). When + * events are available on this fd, call oeffis_dispatch() immediately to + * process the events. + */ +int +oeffis_get_fd(struct oeffis *oeffis); + +/** + * Get a `dup()` of the file descriptor. This function should only be called + * after an event of type @ref OEFFIS_EVENT_CONNECTED_TO_EIS. Otherwise, + * this function returns -1 and errno is set to the `dup()` error. + * If this function is called when liboeffis is not connected to EIS, the errno + * is set to `ENODEV`. + * + * Repeated calls to this functions will return additional duplicated file + * descriptors. There is no need for a well-written application to call this + * function more than once. + * + * The caller is responsible for closing the returned fd. + * + * @return The EIS fd or -1 on failure or before the fd was retrieved. + */ +int +oeffis_get_eis_fd(struct oeffis *oeffis); + +/** + * The bitmask of devices to request. This bitmask matches the devices + * bitmask in the XDG RemoteDesktop portal. + */ +enum oeffis_device { + OEFFIS_DEVICE_ALL_DEVICES = 0, + OEFFIS_DEVICE_KEYBOARD = (1 << 0), + OEFFIS_DEVICE_POINTER = (1 << 1), + OEFFIS_DEVICE_TOUCHSCREEN = (1 << 2), +}; + +/** + * Connect this oeffis instance to a RemoteDesktop session with the given device + * mask selected. + * + * This initiates the DBus communication, starts a RemoteDesktop session and + * selects the devices. On success, the @ref + * OEFFIS_EVENT_CONNECTED_TO_EIS event is created and the EIS fd can be + * retrieved with oeffis_get_eis_fd(). + * + * Any failure in the above process or any other DBus communication error + * once connected, including caller bugs, result in the oeffis context being + * disconnected and an @ref OEFFIS_EVENT_DISCONNECTED event. Once + * disconnected, the context should be released with oeffis_unref(). + * An @ref OEFFIS_EVENT_DISCONNECTED indicates a communication error and + * oeffis_get_error_message() is set with an appropriate error message. + * + * If the RemoteDesktop session is closed by the + * compositor, an @ref OEFFIS_EVENT_CLOSED event is created and the context + * should be released with oeffis_unref(). Unlike a disconnection, an @ref + * OEFFIS_EVENT_CLOSED event signals intentional closure by the portal. For + * example, this may happen as a result of user interaction to terminate the + * RemoteDesktop session. + * + * @note A caller must keep the oeffis context alive after a successful + * connection. Destroying the context results in the DBus connection being + * closed and that again results in our EIS fd being invalidated by the + * portal and/or the EIS implementation. + * + * @warning Due to the asynchronous nature of DBus and libei, it is not + * guaranteed that an event of type @ref OEFFIS_EVENT_DISCONNECTED or @ref + * OEFFIS_EVENT_CLOSED is received before the EIS fd becomes invalid. + * + * @param oeffis A new oeffis context + * @param devices A bitmask of @ref oeffis_device + */ +void +oeffis_create_session(struct oeffis *oeffis, uint32_t devices); + +/** + * See oeffis_create_session() but this function allows to specify the busname to connect to. + * This function should only be used for testing. + */ +void +oeffis_create_session_on_bus(struct oeffis *oeffis, const char *busname, uint32_t devices); + +enum oeffis_event_type { + OEFFIS_EVENT_NONE = 0, /**< No event currently available */ + OEFFIS_EVENT_CONNECTED_TO_EIS, /**< The RemoteDesktop session was created and an eis fd is available */ + OEFFIS_EVENT_CLOSED, /**< The session was closed by the compositor or portal */ + OEFFIS_EVENT_DISCONNECTED, /**< We were disconnected from the Bus due to an error */ +}; + +/** + * Process pending events. This function must be called immediately after + * the file descriptor returned by oeffis_get_fd() signals data is + * available. + * + * After oeffis_dispatch() completes, zero or more events may be available + * by oeffis_get_event(). + */ +void +oeffis_dispatch(struct oeffis *oeffis); + +/** + * Return the next available event, if any. If no event is currently + * available, @ref OEFFIS_EVENT_NONE is returned. + * + * Calling oeffis_dispatch() does not guarantee events are available to the + * caller. A single call oeffis_dispatch() may cause more than one event to + * be available. + */ +enum oeffis_event_type +oeffis_get_event(struct oeffis *oeffis); + +/** + * If the session was @ref OEFFIS_EVENT_DISCONNECTED, return the error message + * that caused the disconnection. The returned string is owned by the oeffis context. + */ +const char * +oeffis_get_error_message(struct oeffis *oeffis); + +/** + * @} + */ + +#ifdef __cplusplus +} +#endif diff --git a/test/templates/__init__.py b/test/templates/__init__.py new file mode 100644 index 0000000..3f181f8 --- /dev/null +++ b/test/templates/__init__.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: MIT +# +# +# This file is formatted with Python Black + +from dbusmock import DBusMockObject +from typing import Dict, Any, NamedTuple, Optional +from itertools import count +from gi.repository import GLib # type: ignore + +import dbus +import dbus.service +import logging + + +ASVType = Dict[str, Any] + +logging.basicConfig(format="%(levelname).1s|%(name)s: %(message)s", level=logging.DEBUG) +logger = logging.getLogger("templates") + + +class MockParams: + """ + Helper class for storing template parameters. The Mock object passed into + ``load()`` is shared between all templates. This makes it easy to have + per-template parameters by calling: + + >>> params = MockParams.get(mock, MAIN_IFACE) + >>> params.version = 1 + + and later, inside a DBus method: + >>> params = MockParams.get(self, MAIN_IFACE) + >>> return params.version + """ + + @classmethod + def get(cls, mock, interface_name): + params = getattr(mock, "params", {}) + try: + return params[interface_name] + except KeyError: + c = cls() + params[interface_name] = c + mock.params = params + return c + + +class Response(NamedTuple): + response: int + results: ASVType + + +class Request: + _token_counter = count() + + def __init__( + self, bus_name: dbus.service.BusName, sender: str, options: Optional[ASVType] + ): + options = options or {} + sender_token = sender.removeprefix(":").replace(".", "_") + handle_token = options.get("handle_token", next(self._token_counter)) + self.sender = sender + self.handle = ( + f"/org/freedesktop/portal/desktop/request/{sender_token}/{handle_token}" + ) + self.mock = DBusMockObject( + bus_name=bus_name, + path=self.handle, + interface="org.freedesktop.portal.Request", + props={}, + ) + self.mock.AddMethod("", "Close", "", "", "self.RemoveObject(self.path)") + logger.debug(f"Request created at {self.handle}") + + def respond(self, response: Response, delay: int = 0): + def respond(): + logger.debug(f"Request.Response on {self.handle}: {response}") + self.mock.EmitSignalDetailed( + "", + "Response", + "ua{sv}", + [dbus.UInt32(response.response), response.results], + details={"destination": self.sender}, + ) + + if delay > 0: + GLib.timeout_add(delay, respond) + else: + respond() + + +class Session: + _token_counter = count() + + def __init__( + self, bus_name: dbus.service.BusName, sender: str, options: Optional[ASVType] + ): + options = options or {} + sender_token = sender.removeprefix(":").replace(".", "_") + handle_token = options.get("session_handle_token", next(self._token_counter)) + self.sender = sender + self.handle = ( + f"/org/freedesktop/portal/desktop/session/{sender_token}/{handle_token}" + ) + self.mock = DBusMockObject( + bus_name=bus_name, + path=self.handle, + interface="org.freedesktop.portal.Session", + props={}, + ) + self.mock.AddMethod("", "Close", "", "", "self.RemoveObject(self.path)") + logger.debug(f"Session created at {self.handle}") + + def close(self, details: ASVType, delay: int = 0): + def respond(): + logger.debug(f"Session.Closed on {self.handle}: {details}") + self.mock.EmitSignalDetailed( + "", "Closed", "a{sv}", [details], destination=self.sender + ) + + if delay > 0: + GLib.timeout_add(delay, respond) + else: + respond() diff --git a/test/templates/remotedesktop.py b/test/templates/remotedesktop.py new file mode 100644 index 0000000..912cad5 --- /dev/null +++ b/test/templates/remotedesktop.py @@ -0,0 +1,336 @@ +# SPDX-License-Identifier: MIT +# +# This file is formatted with Python Black + +from templates import Request, Response, Session, ASVType, MockParams +from typing import Dict, List, Tuple, Iterator +from itertools import count + +import dbus +import dbus.service +import enum +import logging +import socket + +logger = logging.getLogger(f"templates.{__name__}") + +BUS_NAME = "org.freedesktop.portal.Desktop" +MAIN_OBJ = "/org/freedesktop/portal/desktop" +SYSTEM_BUS = False +MAIN_IFACE = "org.freedesktop.portal.RemoteDesktop" + +_restore_tokens = count() + + +class RDSession(Session): + class State(enum.IntEnum): + CREATED = enum.auto() + SELECTED = enum.auto() + STARTED = enum.auto() + CONNECTED = enum.auto() + + @property + def state(self): + try: + return self._session_state + except AttributeError: + self._session_state = RDSession.State.CREATED + return self._session_state + + def advance_state(self): + if self.state != RDSession.State.CONNECTED: + self._session_state += 1 + + +def load(mock, parameters): + logger.debug(f"loading {MAIN_IFACE} template") + + params = MockParams.get(mock, MAIN_IFACE) + params.delay = 500 + params.version = parameters.get("version", 2) + params.response = parameters.get("response", 0) + params.devices = parameters.get("devices", 0b111) + params.sessions: Dict[str, RDSession] = {} + + mock.AddProperties( + MAIN_IFACE, + dbus.Dictionary( + { + "version": dbus.UInt32(params.version), + "AvailableDeviceTypes": dbus.UInt32( + parameters.get("device-types", params.devices) + ), + } + ), + ) + + +@dbus.service.method( + MAIN_IFACE, + sender_keyword="sender", + in_signature="a{sv}", + out_signature="o", +) +def CreateSession(self, options, sender): + try: + logger.debug(f"CreateSession: {options}") + params = MockParams.get(self, MAIN_IFACE) + request = Request(bus_name=self.bus_name, sender=sender, options=options) + + session = RDSession(bus_name=self.bus_name, sender=sender, options=options) + params.sessions[session.handle] = session + + response = Response(params.response, {"session_handle": session.handle}) + + request.respond(response, delay=params.delay) + + return request.handle + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + sender_keyword="sender", + in_signature="oa{sv}", + out_signature="o", +) +def SelectDevices(self, session_handle, options, sender): + try: + logger.debug(f"SelectDevices: {session_handle} {options}") + params = MockParams.get(self, MAIN_IFACE) + request = Request(bus_name=self.bus_name, sender=sender, options=options) + + try: + session = params.sessions[session_handle] + if session.state != RDSession.State.CREATED: + raise dbus.exceptions.DBusException(f"Session in state {session.state}, expected CREATED", + name="org.freedesktop.DBus.Error.AccessDenied") + else: + resp = params.response + if resp == 0: + session.advance_state() + except KeyError: + raise dbus.exceptions.DBusException( + "Invalid session", + name="org.freedesktop.DBus.Error.AccessDenied") + + response = Response(resp, {}) + request.respond(response, delay=params.delay) + + return request.handle + except Exception as e: + logger.critical(e) + if isinstance(e, dbus.exceptions.DBusException): + raise e + + +@dbus.service.method( + MAIN_IFACE, + sender_keyword="sender", + in_signature="osa{sv}", + out_signature="o", +) +def Start(self, session_handle, parent_window, options, sender): + try: + logger.debug(f"Start: {session_handle} {options}") + params = MockParams.get(self, MAIN_IFACE) + request = Request(bus_name=self.bus_name, sender=sender, options=options) + + results = { + "devices": dbus.UInt32(params.devices), + } + + try: + session = params.sessions[session_handle] + if session.state != RDSession.State.SELECTED: + raise dbus.exceptions.DBusException(f"Session in state {session.state}, expected SELECTED", + name="org.freedesktop.DBus.Error.AccessDenied") + else: + resp = params.response + if resp == 0: + session.advance_state() + except KeyError: + raise dbus.exceptions.DBusException( + "Invalid session", + name="org.freedesktop.DBus.Error.AccessDenied") + + response = Response(resp, results) + request.respond(response, delay=params.delay) + + return request.handle + except Exception as e: + logger.critical(e) + if isinstance(e, dbus.exceptions.DBusException): + raise e + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}dd", + out_signature="", +) +def NotifyPointerMotion(self, session_handle, options, dx, dy): + try: + logger.debug(f"NotifyPointerMotion: {session_handle} {options} {dx} {dy}") + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}udd", + out_signature="", +) +def NotifyPointerMotionAbsolute(self, session_handle, options, stream, x, y): + try: + logger.debug( + f"NotifyPointerMotionAbsolute: {session_handle} {options} {stream} {x} {y}" + ) + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}iu", + out_signature="", +) +def NotifyPointerButton(self, session_handle, options, button, state): + try: + logger.debug( + f"NotifyPointerButton: {session_handle} {options} {button} {state}" + ) + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}dd", + out_signature="", +) +def NotifyPointerAxis(self, session_handle, options, dx, dy): + try: + logger.debug(f"NotifyPointerAxis: {session_handle} {options} {dx} {dx}") + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}ui", + out_signature="", +) +def NotifyPointerAxisDiscrete(self, session_handle, options, axis, steps): + try: + logger.debug( + f"NotifyPointerAxisDiscrete: {session_handle} {options} {axis} {steps}" + ) + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}iu", + out_signature="", +) +def NotifyKeyboardKeycode(self, session_handle, options, keycode, state): + try: + logger.debug( + f"NotifyKeyboardKeycode: {session_handle} {options} {keycode} {state}" + ) + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}iu", + out_signature="", +) +def NotifyKeyboardKeysym(self, session_handle, options, keysym, state): + try: + logger.debug( + f"NotifyKeyboardKeysym: {session_handle} {options} {keysym} {state}" + ) + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}uudd", + out_signature="", +) +def NotifyTouchDown(self, session_handle, options, stream, slot, x, y): + try: + logger.debug( + f"NotifyTouchDown: {session_handle} {options} {stream} {slot} {x} {y}" + ) + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}uudd", + out_signature="", +) +def NotifyTouchMotion(self, session_handle, options, stream, slot, x, y): + try: + logger.debug( + f"NotifyTouchMotion: {session_handle} {options} {stream} {slot} {x} {y}" + ) + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}u", + out_signature="", +) +def NotifyTouchUp(self, session_handle, options, slot): + try: + logger.debug(f"NotifyTouchMotion: {session_handle} {options} {slot}") + except Exception as e: + logger.critical(e) + + +@dbus.service.method( + MAIN_IFACE, + in_signature="oa{sv}", + out_signature="h", +) +def ConnectToEIS(self, session_handle, options): + try: + logger.debug(f"ConnectToEIS: {session_handle} {options}") + + params = MockParams.get(self, MAIN_IFACE) + try: + session = params.sessions[session_handle] + if session.state != RDSession.State.STARTED: + logger.error(f"Session in state {session.state}, expected STARTED") + raise dbus.exceptions.DBusException("Session must be started before ConnectToEIS", + name="org.freedesktop.DBus.Error.AccesDenied") + except KeyError: + raise dbus.exceptions.DBusException( + "Invalid session", + name="org.freedesktop.DBus.Error.AccessDenied") + + import socket + + sockets = socket.socketpair() + # Write some random data down so it'll break anything that actually + # expects the socket to be a real EIS socket, plus it makes it + # easy to check if we connected to the right EIS socket in our tests. + sockets[0].send(b"VANILLA") + fd = sockets[1] + logger.debug(f"ConnectToEIS with fd {fd.fileno()}") + return dbus.types.UnixFd(fd) + except Exception as e: + logger.critical(e) + if isinstance(e, dbus.exceptions.DBusException): + raise e diff --git a/test/test_oeffis.py b/test/test_oeffis.py new file mode 100644 index 0000000..6a0ca9d --- /dev/null +++ b/test/test_oeffis.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +# +# SPDX-License-Identifier: MIT +# +# This file is formatted with Python Black +# +# Introduction +# ============ +# +# This is a Python-based test suite making use of DBusMock to test the +# liboeffis.so C library. +# +# The main components are: +# - LibOeffis: the Python class wrapping liboeffis.so via ctypes. +# This is a manually maintained mapping, any API additions/changes must +# updated here. +# - Oeffis: a pythonic wrapper around LibOeffis so the tests look like Python +# - TestOeffis: the test class for all tests that must talk to DBus +# +# DBusMock integration +# ==================== +# +# DBusMock works in that a **separate process** is started that provides a +# DBus session bus that our tests connect to. Templates for dbusmock provide +# the behavior of that bus. Note that the mocked bus cannot be controlled +# from our test code, it's a separate process. Unless you set up special +# DBus signals/methods to talk to it, which we don't. +# +# Any test that requires DBus looks like this: +# +# ```python +# class TestOeffis(): +# .... +# def test_foo(self): +# params = {} +# self.setup_daemon(params) +# # now you can talk to the RemoteDesktop portal +# ``` +# See the RemoteDesktop template for parameters that can be passed in. +# +# DBusMock templates +# ------------------ +# +# See the templates/ directory for the templates used by DBusMock. Templates +# are named after the portal. Available parameters are in the `load()` function. +# + + +from ctypes import c_char_p, c_int, c_uint32, c_void_p +from typing import Dict, List, Tuple, Type, Optional, TextIO +from gi.repository import GLib # type: ignore +from dbus.mainloop.glib import DBusGMainLoop + +import attr +import ctypes +import dbus +import dbusmock +import fcntl +import os +import pytest +import socket +import subprocess + +DBusGMainLoop(set_as_default=True) + +PREFIX = "oeffis_" + + +@attr.s +class _Api: + name: str = attr.ib() + args: Tuple[Type[ctypes._SimpleCData], ...] = attr.ib() + return_type: Optional[Type[ctypes._SimpleCData]] = attr.ib() + + @property + def basename(self) -> str: + return self.name[len(PREFIX) :] + + +@attr.s +class _Enum: + name: str = attr.ib() + value: int = attr.ib() + + @property + def basename(self) -> str: + return self.name[len(PREFIX) :] + + +class LibOeffis(object): + """ + liboeffis.so wrapper. This is a singleton ctypes wrapper into liboeffis.so with + minimal processing. Example: + + >>> lib = LibOeffis.instance() + >>> ctx = lib.oeffis_new(None) + >>> lib.oeffis_unref(ctx) + >>> print(lib.OEFFIS_EVENT_CLOSED) + + In most cases you probably want to use the ``Oeffis`` class instead. + """ + + _lib = None + + @staticmethod + def _cdll(): + return ctypes.CDLL("liboeffis.so", use_errno=True) + + @classmethod + def _load(cls): + cls._lib = cls._cdll() + for api in cls._api_prototypes: + func = getattr(cls._lib, api.name) + func.argtypes = api.args + func.restype = api.return_type + setattr(cls, api.name, func) + + for e in cls._enums: + setattr(cls, e.name, e.value) + + _api_prototypes: List[_Api] = [ + _Api(name="oeffis_new", args=(c_void_p,), return_type=c_void_p), + _Api(name="oeffis_ref", args=(c_void_p,), return_type=c_void_p), + _Api(name="oeffis_unref", args=(c_void_p,), return_type=c_void_p), + _Api(name="oeffis_set_user_data", args=(c_void_p, c_void_p), return_type=None), + _Api(name="oeffis_get_user_data", args=(c_void_p,), return_type=c_void_p), + _Api(name="oeffis_get_fd", args=(c_void_p,), return_type=c_int), + _Api(name="oeffis_get_eis_fd", args=(c_void_p,), return_type=c_int), + _Api(name="oeffis_create_session", args=(c_void_p, c_uint32), return_type=None), + _Api( + name="oeffis_create_session_on_bus", + args=(c_void_p, c_char_p, c_uint32), + return_type=None, + ), + _Api(name="oeffis_dispatch", args=(c_void_p,), return_type=None), + _Api(name="oeffis_get_event", args=(c_void_p,), return_type=c_int), + _Api(name="oeffis_get_error_message", args=(c_void_p,), return_type=c_char_p), + ] + + _enums: List[_Enum] = [ + _Enum(name="OEFFIS_DEVICE_ALL_DEVICES", value=0), + _Enum(name="OEFFIS_DEVICE_KEYBOARD", value=1), + _Enum(name="OEFFIS_DEVICE_POINTER", value=2), + _Enum(name="OEFFIS_DEVICE_TOUCHSCREEN", value=4), + _Enum(name="OEFFIS_EVENT_NONE", value=0), + _Enum(name="OEFFIS_EVENT_CONNECTED_TO_EIS", value=1), + _Enum(name="OEFFIS_EVENT_CLOSED", value=2), + _Enum(name="OEFFIS_EVENT_DISCONNECTED", value=3), + ] + + @classmethod + def instance(cls): + if cls._lib is None: + cls._load() + return cls + + +class Oeffis: + """ + Convenience wrapper to make using liboeffis a bit more pythonic. + + >>> o = Oeffis() + >>> fd = o.fd + >>> o.create_session(o.DEVICE_POINTER) + + """ + + def __init__(self, userdata=None): + l = LibOeffis.instance() + self.ctx = l.oeffis_new(userdata) # type: ignore + + def wrapper(func): + return lambda *args, **kwargs: func(self.ctx, *args, **kwargs) + + for api in l._api_prototypes: + # skip some APIs that are not be exposed because they don't make sense + # to have in python. + if api.name not in ( + "oeffis_ref", + "oeffis_unref", + "oeffis_get_user_data", + "oeffis_set_user_data", + ): + func = getattr(l, api.name) + setattr(self, api.basename, wrapper(func)) + + for e in l._enums: + val = getattr(l, e.name) + setattr(self, e.basename, val) + + @property + def fd(self) -> TextIO: + """ + Return the fd we need to monitor for oeffis_dispatch() + """ + return os.fdopen(self.get_fd(), "rb") # type: ignore + + @property + def eis_fd(self) -> Optional[socket.socket]: + """Return the socket connecting us to the EIS implementation or None if we're not ready/disconnected""" + fd = self.get_eis_fd() # type: ignore + if fd != -1: + return socket.socket(fileno=fd) + else: + return None + + @property + def error_message(self) -> Optional[str]: + return self.get_error_message() # type: ignore + + def __del__(self): + LibOeffis.instance().oeffis_unref(self.ctx) # type: ignore + + +@pytest.fixture() +def liboeffis(): + return LibOeffis.instance() + + +def test_ref_unref(liboeffis): + o = liboeffis.oeffis_new(None) + assert o is not None + o2 = liboeffis.oeffis_ref(o) + assert o2 == o + assert liboeffis.oeffis_unref(o) is None + assert liboeffis.oeffis_unref(o2) is None + assert liboeffis.oeffis_unref(None) is None + + +def test_set_user_data(liboeffis): + o = liboeffis.oeffis_new(None) + assert liboeffis.oeffis_get_user_data(o) is None + liboeffis.oeffis_unref(o) + + data = ctypes.pointer(ctypes.c_int(52)) + o = liboeffis.oeffis_new(data) + assert o is not None + + +def test_ctx(): + oeffis = Oeffis() + assert oeffis.error_message is None + + fd = oeffis.fd + assert fd is not None + + eisfd = oeffis.eis_fd + assert eisfd is None + + +def test_error_out(): + # Bus doesn't exist + oeffis = Oeffis() + oeffis.create_session_on_bus(b"org.freedesktop.OeffisTest", oeffis.DEVICE_POINTER) + oeffis.dispatch() + e = oeffis.get_event() + assert e == oeffis.EVENT_DISCONNECTED + assert oeffis.error_message is not None + + +# Uncomment this to have dbus-monitor listen on the normal session address +# rather than the test DBus. This can be useful for cases where *something* +# messes up and tests run against the wrong bus. +# +# session_dbus_address = os.environ["DBUS_SESSION_BUS_ADDRESS"] + + +def start_dbus_monitor() -> "subprocess.Process": + import subprocess + + env = os.environ.copy() + try: + env["DBUS_SESSION_BUS_ADDRESS"] = session_dbus_address + except NameError: + # See comment above + pass + + argv = ["dbus-monitor", "--session"] + mon = subprocess.Popen(argv, env=env) + + def stop_dbus_monitor(): + mon.terminate() + mon.wait() + + GLib.timeout_add(2000, stop_dbus_monitor) + return mon + + +class TestOeffis(dbusmock.DBusTestCase): + """ + Test class that sets up a mocked DBus session bus to be used by liboeffis.so. + """ + @classmethod + def setUpClass(cls): + cls.PORTAL_NAME = "RemoteDesktop" + cls.INTERFACE_NAME = f"org.freedesktop.portal.{cls.PORTAL_NAME}" + + def setUp(self): + self.p_mock = None + self._mainloop = None + self.dbus_monitor = None + + def setup_daemon(self, params=None, extra_templates: List[Tuple[str, Dict]] = []): + """ + Start a DBusMock daemon in a separate process. + + If extra_templates is specified, it is a list of tuples with the + portal name as first value and the param dict to be passed to that + template as second value, e.g. ("ScreenCast", {...}). + """ + self.start_session_bus() + self.p_mock, self.obj_portal = self.spawn_server_template( + template=f"templates/{self.PORTAL_NAME.lower()}.py", + parameters=params or {}, + stdout=subprocess.PIPE, + ) + + flags = fcntl.fcntl(self.p_mock.stdout, fcntl.F_GETFL) + fcntl.fcntl(self.p_mock.stdout, fcntl.F_SETFL, flags | os.O_NONBLOCK) + self.mock_interface = dbus.Interface(self.obj_portal, dbusmock.MOCK_IFACE) + self.properties_interface = dbus.Interface( + self.obj_portal, dbus.PROPERTIES_IFACE + ) + self.portal_interface = dbus.Interface(self.obj_portal, self.INTERFACE_NAME) + + for t, tparams in extra_templates: + template = f"templates/{t.lower()}.py" + self.obj_portal.AddTemplate( + template, + dbus.Dictionary(tparams, signature="sv"), + dbus_interface=dbusmock.MOCK_IFACE, + ) + + self.dbus_monitor = start_dbus_monitor() + self._mainloop = None + + def tearDown(self): + if self.p_mock: + if self.p_mock.stdout: + out = (self.p_mock.stdout.read() or b"").decode("utf-8") + if out: + print(out) + self.p_mock.stdout.close() + self.p_mock.terminate() + self.p_mock.wait() + + if self.dbus_monitor: + self.dbus_monitor.terminate() + self.dbus_monitor.wait() + + @property + def mainloop(self): + """ + The mainloop for this test. This mainloop automatically quits after a + fixed timeout, but only on the first run. That's usually enough for + tests, if you need to call mainloop.run() repeatedly ensure that a + timeout handler is set to ensure quick test case failure in case of + error. + """ + if self._mainloop is None: + + def quit(): + self._mainloop.quit() # type: ignore + self._mainloop = None + + self._mainloop = GLib.MainLoop() + GLib.timeout_add(2000, quit) + + return self._mainloop + + def test_create_session(self): + self.setup_daemon() + + oeffis = Oeffis() + oeffis.create_session(oeffis.DEVICE_POINTER | oeffis.DEVICE_KEYBOARD) # type: ignore + oeffis.dispatch() # type: ignore + + def _dispatch(source, condition): + oeffis.dispatch() # type: ignore + return True + + GLib.io_add_watch(oeffis.fd, 0, GLib.IO_IN, _dispatch) + + self.mainloop.run() + + e = oeffis.get_event() # type: ignore + assert e == oeffis.EVENT_CONNECTED_TO_EIS, oeffis.error_message # type: ignore + + eisfd = oeffis.eis_fd + assert eisfd is not None + + assert eisfd.recv(64) == b"VANILLA" # that's what the template sends diff --git a/tools/oeffis-demo-tool.c b/tools/oeffis-demo-tool.c new file mode 100644 index 0000000..99f90ad --- /dev/null +++ b/tools/oeffis-demo-tool.c @@ -0,0 +1,101 @@ +/* SPDX-License-Identifier: MIT */ +/* + * Copyright © 2020 Red Hat, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +/* A simple tool that connects to an EIS implementation via the RemoteDesktop + * portal using liboeffis + */ + +#include "config.h" + +#include +#include +#include +#include +#include + +#include "src/util-macros.h" +#include "src/util-mem.h" +#include "src/util-io.h" + +#include "liboeffis.h" + +DEFINE_UNREF_CLEANUP_FUNC(oeffis); + +static bool stop = false; + +static void sighandler(int signal) { + stop = true; +} + +int main(int argc, char **argv) +{ + _unref_(oeffis) *oeffis = oeffis_new(NULL); + + signal(SIGINT, sighandler); + + struct pollfd fds = { + .fd = oeffis_get_fd(oeffis), + .events = POLLIN, + .revents = 0, + }; + + oeffis_create_session(oeffis, OEFFIS_DEVICE_POINTER|OEFFIS_DEVICE_KEYBOARD); + + while (!stop && poll(&fds, 1, 1000) > -1) { + oeffis_dispatch(oeffis); + + enum oeffis_event_type e = oeffis_get_event(oeffis); + switch (e) { + case OEFFIS_EVENT_NONE: + break; + case OEFFIS_EVENT_CONNECTED_TO_EIS: { + _cleanup_close_ int eisfd = oeffis_get_eis_fd(oeffis); + printf("We have an eisfd: lucky number %d\n", eisfd); + /* Note: unless we get closed/disconnected, we are + * started now, or will be once the compositor is + * happy with it. + * + * We still need to keep dispatching events though + * to be able to receive the Session.Closed signal + * or any disconnections. And we must keep the + * oeffis context alive - once the context is + * destroyed we disconnect from DBus which will + * cause the compositor or the portal to invalidate + * our EIS fd. + */ + break; + } + case OEFFIS_EVENT_DISCONNECTED: + fprintf(stderr, "Disconnected: %s\n", oeffis_get_error_message(oeffis)); + stop = true; + break; + case OEFFIS_EVENT_CLOSED: + printf("Closing\n"); + stop = true; + break; + } + } + + return 0; +}