From 85a99257db3735a01ccf2c87b1449fd61acc5883 Mon Sep 17 00:00:00 2001 From: Scott Moreau Date: Tue, 7 Nov 2023 08:25:50 -0700 Subject: [PATCH] Add obs plugin --- .github/workflows/ci.yaml | 4 +- ipc-scripts/set-obs-effect.py | 30 +++ ipc-scripts/trailfocus.py | 40 +++ ipc-scripts/wayfire_socket.py | 69 +++++ meson.build | 1 + metadata/meson.build | 2 +- metadata/obs.xml | 8 + src/meson.build | 6 + src/obs.cpp | 482 ++++++++++++++++++++++++++++++++++ 9 files changed, 639 insertions(+), 3 deletions(-) create mode 100755 ipc-scripts/set-obs-effect.py create mode 100755 ipc-scripts/trailfocus.py create mode 100644 ipc-scripts/wayfire_socket.py create mode 100644 metadata/obs.xml create mode 100644 src/obs.cpp diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 31b5e5c..915b7e0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest container: alpine:edge steps: - - run: apk --no-cache add git gcc g++ binutils pkgconf meson ninja musl-dev wayland-dev wayland-protocols libinput-dev libevdev-dev libxkbcommon-dev pixman-dev glm-dev libdrm-dev mesa-dev cairo-dev pango-dev eudev-dev libxml2-dev glibmm-dev libseat-dev hwdata + - run: apk --no-cache add git gcc g++ binutils pkgconf meson ninja musl-dev wayland-dev wayland-protocols libinput-dev libevdev-dev libxkbcommon-dev pixman-dev glm-dev libdrm-dev mesa-dev cairo-dev pango-dev eudev-dev libxml2-dev glibmm-dev libseat-dev hwdata nlohmann-json - name: Wayfire uses: actions/checkout@v2 with: @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest container: alpine:edge steps: - - run: apk --no-cache add git gcc g++ binutils pkgconf meson ninja musl-dev wayland-dev wayland-protocols libinput-dev libevdev-dev libxkbcommon-dev pixman-dev glm-dev libdrm-dev mesa-dev cairo-dev pango-dev eudev-dev libxml2-dev glibmm-dev libseat-dev libxcb-dev xcb-util-wm-dev xwayland hwdata + - run: apk --no-cache add git gcc g++ binutils pkgconf meson ninja musl-dev wayland-dev wayland-protocols libinput-dev libevdev-dev libxkbcommon-dev pixman-dev glm-dev libdrm-dev mesa-dev cairo-dev pango-dev eudev-dev libxml2-dev glibmm-dev libseat-dev libxcb-dev xcb-util-wm-dev xwayland hwdata nlohmann-json - name: Wayfire uses: actions/checkout@v2 with: diff --git a/ipc-scripts/set-obs-effect.py b/ipc-scripts/set-obs-effect.py new file mode 100755 index 0000000..4c1fadf --- /dev/null +++ b/ipc-scripts/set-obs-effect.py @@ -0,0 +1,30 @@ +#!/usr/bin/python3 +# +# This is a simple script which demonstrates how to use the Wayfire OBS plugin using the IPC socket with the wayfire_socket.py helper. +# To use this, make sure that the ipc plugin is first in the plugin list, so that the WAYFIRE_SOCKET environment +# variable propagates to all processes, including autostarted processes. Also make sure to enable stipc and obs plugins. +# +# This script can be run from a terminal to change the opacity, brightness and saturation of views. +# Usage: ./script.py +# where is one of opacity, brightness or saturation, is in the range 0-1 and is the animation duration in milliseconds + +import os +import sys +from wayfire_socket import * + +addr = os.getenv('WAYFIRE_SOCKET') + +# Important: we connect to Wayfire's IPC two times. The one socket is used for reading events (view-mapped, view-focused, etc). +# The other is used for sending commands and querying Wayfire. +# We could use the same socket, but this would complicate reading responses, as events and query responses would be mixed with just one socket. +commands_sock = WayfireSocket(addr) + +for v in commands_sock.list_views(): + if v["app-id"] == sys.argv[1]: + if sys.argv[2] == "opacity": + commands_sock.set_view_opacity(v["id"], float(sys.argv[3]), int(sys.argv[4])) + elif sys.argv[2] == "brightness": + commands_sock.set_view_brightness(v["id"], float(sys.argv[3]), int(sys.argv[4])) + elif sys.argv[2] == "saturation": + commands_sock.set_view_saturation(v["id"], float(sys.argv[3]), int(sys.argv[4])) + diff --git a/ipc-scripts/trailfocus.py b/ipc-scripts/trailfocus.py new file mode 100755 index 0000000..10301b3 --- /dev/null +++ b/ipc-scripts/trailfocus.py @@ -0,0 +1,40 @@ +#!/usr/bin/python3 + +import os +import sys +from wayfire_socket import * + +addr = os.getenv('WAYFIRE_SOCKET') + +commands_sock = WayfireSocket(addr) +commands_sock.watch() + +while True: + try: + msg = commands_sock.read_message() + except KeyboardInterrupt: + for v in commands_sock.list_views(): + commands_sock.set_view_opacity(v["id"], 1.0, 500) + commands_sock.set_view_brightness(v["id"], 1.0, 500) + commands_sock.set_view_saturation(v["id"], 1.0, 500) + exit(0) + + if "event" in msg and msg["event"] == "view-focused": + i = 0 + for v in commands_sock.list_views(): + if v["app-id"] == "$unfocus panel" or v["app-id"] == "gtk-layer-shell": + continue + if v["state"] != {} and v["state"]["minimized"]: + continue + i += 1 + step = 0.7 / i + value = 0.3 + for v in commands_sock.list_views()[::-1]: + if v["app-id"] == "$unfocus panel" or v["app-id"] == "gtk-layer-shell": + continue + if v["state"] != {} and v["state"]["minimized"]: + continue + value += step + commands_sock.set_view_opacity(v["id"], value, 1000) + commands_sock.set_view_brightness(v["id"], value, 1000) + commands_sock.set_view_saturation(v["id"], value, 1000) diff --git a/ipc-scripts/wayfire_socket.py b/ipc-scripts/wayfire_socket.py new file mode 100644 index 0000000..a3779d7 --- /dev/null +++ b/ipc-scripts/wayfire_socket.py @@ -0,0 +1,69 @@ +import socket +import json as js + +def get_msg_template(method: str): + # Create generic message template + message = {} + message["method"] = method + message["data"] = {} + return message + +class WayfireSocket: + def __init__(self, socket_name): + self.client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.client.connect(socket_name) + + def read_exact(self, n): + response = bytes() + while n > 0: + read_this_time = self.client.recv(n) + if not read_this_time: + raise Exception("Failed to read anything from the socket!") + n -= len(read_this_time) + response += read_this_time + + return response + + def read_message(self): + rlen = int.from_bytes(self.read_exact(4), byteorder="little") + response_message = self.read_exact(rlen) + return js.loads(response_message) + + def send_json(self, msg): + data = js.dumps(msg).encode('utf8') + header = len(data).to_bytes(4, byteorder="little") + self.client.send(header) + self.client.send(data) + return self.read_message() + + def watch(self): + message = get_msg_template("window-rules/events/watch") + return self.send_json(message) + + def set_view_opacity(self, view_id: int, opacity: float, duration: int): + message = get_msg_template("wf/obs/set-view-opacity") + message["data"] = {} + message["data"]["view-id"] = view_id + message["data"]["opacity"] = opacity + message["data"]["duration"] = duration + return self.send_json(message) + + def set_view_brightness(self, view_id: int, brightness: float, duration: int): + message = get_msg_template("wf/obs/set-view-brightness") + message["data"] = {} + message["data"]["view-id"] = view_id + message["data"]["brightness"] = brightness + message["data"]["duration"] = duration + return self.send_json(message) + + def set_view_saturation(self, view_id: int, saturation: float, duration: int): + message = get_msg_template("wf/obs/set-view-saturation") + message["data"] = {} + message["data"]["view-id"] = view_id + message["data"]["saturation"] = saturation + message["data"]["duration"] = duration + return self.send_json(message) + + def list_views(self): + message = get_msg_template("stipc/list_views") + return self.send_json(message) diff --git a/meson.build b/meson.build index 573e233..fc87d9b 100644 --- a/meson.build +++ b/meson.build @@ -18,6 +18,7 @@ giomm = dependency('giomm-2.4', required: false) wayland_protos = dependency('wayland-protocols', version: '>=1.12') wayland_server = dependency('wayland-server') evdev = dependency('libevdev') +json = dependency('nlohmann_json', required: false) if get_option('enable_windecor') == true windecor = subproject('windecor') diff --git a/metadata/meson.build b/metadata/meson.build index 57174c3..fea7aa0 100644 --- a/metadata/meson.build +++ b/metadata/meson.build @@ -3,13 +3,13 @@ install_data('autorotate-iio.xml', install_dir: wayfire.get_variable(pkgconfig: install_data('background-view.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('bench.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('crosshair.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) -install_data('focus-steal-prevent.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('follow-focus.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('force-fullscreen.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('hide-cursor.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('join-views.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('keycolor.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('mag.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) +install_data('obs.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('showrepaint.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('view-shot.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) install_data('water.xml', install_dir: wayfire.get_variable(pkgconfig: 'metadatadir')) diff --git a/metadata/obs.xml b/metadata/obs.xml new file mode 100644 index 0000000..92befb5 --- /dev/null +++ b/metadata/obs.xml @@ -0,0 +1,8 @@ + + + + <_short>OBS + <_long>Change the opacity, brightness and saturation of windows using ipc scripts + Effects + + diff --git a/src/meson.build b/src/meson.build index 964f035..74c4803 100644 --- a/src/meson.build +++ b/src/meson.build @@ -54,6 +54,12 @@ magnifier = shared_module('mag', 'mag.cpp', dependencies: [wayfire], install: true, install_dir: join_paths(get_option('libdir'), 'wayfire')) +if json.found() +obs = shared_module('obs', 'obs.cpp', + dependencies: [wayfire, json], + install: true, install_dir: join_paths(get_option('libdir'), 'wayfire')) +endif + showrepaint = shared_module('showrepaint', 'showrepaint.cpp', dependencies: [wayfire], install: true, install_dir: join_paths(get_option('libdir'), 'wayfire')) diff --git a/src/obs.cpp b/src/obs.cpp new file mode 100644 index 0000000..c1ba050 --- /dev/null +++ b/src/obs.cpp @@ -0,0 +1,482 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 Scott Moreau + * + * 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 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +static const char *vertex_shader = + R"( +#version 100 + +attribute mediump vec2 position; +attribute mediump vec2 texcoord; + +varying mediump vec2 uvpos; + +uniform mat4 mvp; + +void main() { + + gl_Position = mvp * vec4(position.xy, 0.0, 1.0); + uvpos = texcoord; +} +)"; + +static const char *fragment_shader = + R"( +#version 100 +@builtin_ext@ +@builtin@ + +precision mediump float; + +/* Input uniforms are 0-1 range. */ +uniform mediump float opacity; +uniform mediump float brightness; +uniform mediump float saturation; + +varying mediump vec2 uvpos; + +vec3 saturate(vec3 rgb, float adjustment) +{ + // Algorithm from Chapter 16 of OpenGL Shading Language + const vec3 w = vec3(0.2125, 0.7154, 0.0721); + vec3 intensity = vec3(dot(rgb, w)); + return mix(intensity, rgb, adjustment); +} + +void main() +{ + vec4 c = get_pixel(uvpos); + /* opacity */ + c = c * opacity; + /* brightness */ + c = vec4(c.rgb * brightness, c.a); + /* saturation */ + c = vec4(saturate(c.rgb, saturation), c.a); + gl_FragColor = c; +} +)"; + +namespace wf +{ +namespace scene +{ +namespace obs +{ +const std::string transformer_name = "obs"; + +class wf_obs : public wf::scene::view_2d_transformer_t +{ + wayfire_view view; + OpenGL::program_t *program; + std::unique_ptr opacity; + std::unique_ptr brightness; + std::unique_ptr saturation; + + public: + class simple_node_render_instance_t : public wf::scene::transformer_render_instance_t + { + wf::signal::connection_t on_node_damaged = + [=] (node_damage_signal *ev) + { + push_to_parent(ev->region); + }; + + wf_obs *self; + wayfire_view view; + wf::output_t *wo = nullptr; + wf::effect_hook_t pre_hook; + damage_callback push_to_parent; + + public: + simple_node_render_instance_t(wf_obs *self, damage_callback push_damage, + wayfire_view view) : wf::scene::transformer_render_instance_t(self, + push_damage, + view->get_output()) + { + this->self = self; + this->view = view; + this->push_to_parent = push_damage; + self->connect(&on_node_damaged); + + if (view->get_output()) + { + wo = view->get_output(); + pre_hook = [=] () + { + if (this->self->progression_running()) + { + this->view->damage(); + } else + { + wo->render->rem_effect(&pre_hook); + if (this->self->transformer_inert() && + view->get_transformed_node()->get_transformer(transformer_name)) + { + self->disconnect(&on_node_damaged); + view->get_transformed_node()->rem_transformer(transformer_name); + } + } + }; + } + } + + ~simple_node_render_instance_t() + { + if (wo) + { + wo->render->rem_effect(&pre_hook); + } + } + + void schedule_instructions( + std::vector& instructions, + const wf::render_target_t& target, wf::region_t& damage) + { + // We want to render ourselves only, the node does not have children + instructions.push_back(render_instruction_t{ + .instance = this, + .target = target, + .damage = damage & self->get_bounding_box(), + }); + if (wo && this->self->progression_running()) + { + wo->render->add_effect(&pre_hook, wf::OUTPUT_EFFECT_PRE); + } + } + + void render(const wf::render_target_t& target, + const wf::region_t& region) + { + wlr_box fb_geom = + target.framebuffer_box_from_geometry_box(target.geometry); + auto view_box = target.framebuffer_box_from_geometry_box( + self->get_children_bounding_box()); + view_box.x -= fb_geom.x; + view_box.y -= fb_geom.y; + + float x = view_box.x, y = view_box.y, w = view_box.width, + h = view_box.height; + + static const float vertexData[] = { + -1.0f, -1.0f, + 1.0f, -1.0f, + 1.0f, 1.0f, + -1.0f, 1.0f + }; + static const float texCoords[] = { + 0.0f, 0.0f, + 1.0f, 0.0f, + 1.0f, 1.0f, + 0.0f, 1.0f + }; + + OpenGL::render_begin(target); + + /* Upload data to shader */ + auto src_tex = wf::scene::transformer_render_instance_t::get_texture( + 1.0); + this->self->program->use(src_tex.type); + this->self->program->uniform1f("opacity", this->self->get_opacity()); + this->self->program->uniform1f("brightness", this->self->get_brightness()); + this->self->program->uniform1f("saturation", this->self->get_saturation()); + this->self->program->attrib_pointer("position", 2, 0, vertexData); + this->self->program->attrib_pointer("texcoord", 2, 0, texCoords); + this->self->program->uniformMatrix4f("mvp", target.transform); + GL_CALL(glActiveTexture(GL_TEXTURE0)); + this->self->program->set_active_texture(src_tex); + + /* Render it to target */ + target.bind(); + GL_CALL(glViewport(x, fb_geom.height - y - h, w, h)); + + GL_CALL(glEnable(GL_BLEND)); + GL_CALL(glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA)); + + for (const auto& box : region) + { + target.logic_scissor(wlr_box_from_pixman_box(box)); + GL_CALL(glDrawArrays(GL_TRIANGLE_FAN, 0, 4)); + } + + /* Disable stuff */ + GL_CALL(glDisable(GL_BLEND)); + GL_CALL(glActiveTexture(GL_TEXTURE0)); + GL_CALL(glBindTexture(GL_TEXTURE_2D, 0)); + GL_CALL(glBindFramebuffer(GL_FRAMEBUFFER, 0)); + + this->self->program->deactivate(); + OpenGL::render_end(); + } + }; + + wf_obs(wayfire_view view, OpenGL::program_t *program) : wf::scene::view_2d_transformer_t(view) + { + this->view = view; + this->program = program; + + opacity = std::make_unique(wf::create_option(500)); + brightness = std::make_unique(wf::create_option(500)); + saturation = std::make_unique(wf::create_option(500)); + + opacity->set(1.0, 1.0); + brightness->set(1.0, 1.0); + saturation->set(1.0, 1.0); + } + + void gen_render_instances(std::vector& instances, + damage_callback push_damage, wf::output_t *shown_on) override + { + instances.push_back(std::make_unique( + this, push_damage, view)); + } + + void set_opacity_duration(int duration) + { + double o = *opacity; + opacity.reset(); + opacity = std::make_unique(wf::create_option(duration)); + opacity->set(o, o); + } + + void set_brightness_duration(int duration) + { + double b = *brightness; + brightness.reset(); + brightness = std::make_unique(wf::create_option(duration)); + brightness->set(b, b); + } + + void set_saturation_duration(int duration) + { + double s = *saturation; + saturation.reset(); + saturation = std::make_unique(wf::create_option(duration)); + saturation->set(s, s); + } + + bool transformer_inert() + { + return *opacity > 0.99 && *brightness > 0.99 && + *saturation > 0.99; + } + + bool progression_running() + { + return opacity->running() || brightness->running() || + saturation->running(); + } + + float get_opacity() + { + return *opacity; + } + + float get_brightness() + { + return *brightness; + } + + float get_saturation() + { + return *saturation; + } + + void set_opacity(float target, int duration) + { + set_opacity_duration(duration); + opacity->animate(target); + this->view->damage(); + } + + void set_brightness(float target, int duration) + { + set_brightness_duration(duration); + brightness->animate(target); + this->view->damage(); + } + + void set_saturation(float target, int duration) + { + set_saturation_duration(duration); + saturation->animate(target); + this->view->damage(); + } + + virtual ~wf_obs() + { + opacity.reset(); + brightness.reset(); + saturation.reset(); + } +}; + +class wayfire_obs : public wf::plugin_interface_t +{ + OpenGL::program_t program; + std::map> transformers; + wf::shared_data::ref_ptr_t ipc_repo; + + void pop_transformer(wayfire_view view) + { + if (view->get_transformed_node()->get_transformer(transformer_name)) + { + view->get_transformed_node()->rem_transformer(transformer_name); + } + } + + void remove_transformers() + { + for (auto& view : wf::get_core().get_all_views()) + { + pop_transformer(view); + } + } + + public: + void init() override + { + ipc_repo->register_method("wf/obs/set-view-opacity", ipc_set_view_opacity); + ipc_repo->register_method("wf/obs/set-view-brightness", ipc_set_view_brightness); + ipc_repo->register_method("wf/obs/set-view-saturation", ipc_set_view_saturation); + + OpenGL::render_begin(); + program.compile(vertex_shader, fragment_shader); + OpenGL::render_end(); + } + + std::shared_ptr ensure_transformer(wayfire_view view) + { + auto tmgr = view->get_transformed_node(); + if (!tmgr->get_transformer(transformer_name)) + { + auto node = std::make_shared(view, &program); + tmgr->add_transformer(node, wf::TRANSFORMER_2D, transformer_name); + } + + return tmgr->get_transformer(transformer_name); + } + + void adjust_opacity(wayfire_view view, float opacity, int duration) + { + transformers[view]->set_opacity(opacity, duration); + } + + void adjust_brightness(wayfire_view view, float brightness, int duration) + { + transformers[view]->set_brightness(brightness, duration); + } + + void adjust_saturation(wayfire_view view, float saturation, int duration) + { + transformers[view]->set_saturation(saturation, duration); + } + + wf::ipc::method_callback ipc_set_view_opacity = [=] (nlohmann::json data) -> nlohmann::json + { + WFJSON_EXPECT_FIELD(data, "view-id", number_unsigned); + WFJSON_EXPECT_FIELD(data, "opacity", number); + WFJSON_EXPECT_FIELD(data, "duration", number); + + auto view = wf::ipc::find_view_by_id(data["view-id"]); + if (view && view->is_mapped()) + { + transformers[view] = ensure_transformer(view); + adjust_opacity(view, data["opacity"], data["duration"]); + } else + { + return wf::ipc::json_error("Failed to find view with given id. Maybe it was closed?"); + } + + return wf::ipc::json_ok(); + }; + + wf::ipc::method_callback ipc_set_view_brightness = [=] (nlohmann::json data) -> nlohmann::json + { + WFJSON_EXPECT_FIELD(data, "view-id", number_unsigned); + WFJSON_EXPECT_FIELD(data, "brightness", number); + WFJSON_EXPECT_FIELD(data, "duration", number); + + auto view = wf::ipc::find_view_by_id(data["view-id"]); + if (view && view->is_mapped()) + { + transformers[view] = ensure_transformer(view); + adjust_brightness(view, data["brightness"], data["duration"]); + } else + { + return wf::ipc::json_error("Failed to find view with given id. Maybe it was closed?"); + } + + return wf::ipc::json_ok(); + }; + + wf::ipc::method_callback ipc_set_view_saturation = [=] (nlohmann::json data) -> nlohmann::json + { + WFJSON_EXPECT_FIELD(data, "view-id", number_unsigned); + WFJSON_EXPECT_FIELD(data, "saturation", number); + WFJSON_EXPECT_FIELD(data, "duration", number); + + auto view = wf::ipc::find_view_by_id(data["view-id"]); + if (view && view->is_mapped()) + { + transformers[view] = ensure_transformer(view); + adjust_saturation(view, data["saturation"], data["duration"]); + } else + { + return wf::ipc::json_error("Failed to find view with given id. Maybe it was closed?"); + } + + return wf::ipc::json_ok(); + }; + + void fini() override + { + ipc_repo->unregister_method("wf/obs/set-view-opacity"); + ipc_repo->unregister_method("wf/obs/set-view-brightness"); + ipc_repo->unregister_method("wf/obs/set-view-saturation"); + + remove_transformers(); + + OpenGL::render_begin(); + program.free_resources(); + OpenGL::render_end(); + } +}; +} +} +} + +DECLARE_WAYFIRE_PLUGIN(wf::scene::obs::wayfire_obs);