diff --git a/CMakeLists.txt b/CMakeLists.txt index 40bad8f..35a4d10 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,15 @@ cmake_minimum_required(VERSION 3.20) -project(obs-webkit-browser VERSION 0.1.0 LANGUAGES C) +project(obs-webkit-browser VERSION 0.1.0 LANGUAGES C CXX) include(GNUInstallDirs) find_package(PkgConfig REQUIRED) find_package(Threads REQUIRED) pkg_check_modules(OBS REQUIRED IMPORTED_TARGET libobs) +pkg_check_modules(OBS_FRONTEND REQUIRED IMPORTED_TARGET obs-frontend-api) +pkg_check_modules(QT_WIDGETS REQUIRED IMPORTED_TARGET Qt6Widgets) +find_path(OBS_HEADER_DIRECTORY obs.h PATH_SUFFIXES obs REQUIRED) pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1) @@ -14,12 +17,20 @@ pkg_check_modules(X11_CAPTURE REQUIRED IMPORTED_TARGET x11 xcomposite xdamage xe add_library(obs-webkit-browser MODULE src/plugin.c + src/docks.cpp src/protocol.h ) target_compile_features(obs-webkit-browser PRIVATE c_std_11) +target_compile_features(obs-webkit-browser PRIVATE cxx_std_17) target_compile_definitions(obs-webkit-browser PRIVATE _GNU_SOURCE) target_compile_options(obs-webkit-browser PRIVATE -Wall -Wextra -Wpedantic) -target_link_libraries(obs-webkit-browser PRIVATE PkgConfig::OBS Threads::Threads) +target_include_directories(obs-webkit-browser PRIVATE "${OBS_HEADER_DIRECTORY}") +target_link_libraries(obs-webkit-browser PRIVATE + obs + obs-frontend-api + PkgConfig::QT_WIDGETS + Threads::Threads +) set_target_properties(obs-webkit-browser PROPERTIES PREFIX "") add_executable(obs-webkit-renderer diff --git a/README.md b/README.md index d55b554..8c2e0a6 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ DMA-BUF. Кадры передаются в OBS напрямую как BGRA, б - shared-memory транспорт кадров без многомегабайтной записи в pipe; - мышь, прокрутка, фокус и базовый ввод с клавиатуры через окно Interact OBS; - перезагрузка страницы и остановка renderer-процесса у скрытого source. +- пользовательские web-панели в меню «Док-панели» OBS с сохранением URL в + коллекции сцен, восстановлением после запуска и интерактивным вводом. Звук страницы намеренно выключен: добавьте в OBS отдельный Application Audio Capture. Renderer наследует X11/XWayland-дисплей OBS; его нативное окно имеет нулевую opacity, @@ -31,7 +33,7 @@ DRM render node; для него нужен доступ к `/dev/dri/renderD*`. WebKit сам распределяет элементы по GPU-слоям. Это рабочий backend, а не замена всех расширенных возможностей штатного obs-browser -(DevTools, cookies API, browser docks и DRM здесь пока отсутствуют). +(DevTools, cookies API и DRM здесь пока отсутствуют). ## Сборка на Alpine @@ -45,6 +47,9 @@ doas /usr/bin/cmake --install build --prefix /usr ``` После установки перезапустите OBS и добавьте источник **Браузер WebKit (без CEF)**. +Для панели откройте **Док-панели → Пользовательские web-панели...**, добавьте +название и URL и нажмите **Применить**. Панель можно скрывать и показывать через +обычное меню доков OBS; правый клик по странице открывает команду перезагрузки. Если OBS установлен в нестандартный prefix, задайте при конфигурации `OBS_PLUGIN_DIR` и `OBS_PLUGIN_DATA_DIR`. diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 7d14f90..b72c008 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -1,4 +1,14 @@ WebKitBrowserSource="WebKit Browser (no CEF)" +CustomBrowserDocks="Custom Web Panels..." +CustomBrowserDocksDescription="Add web panels to the OBS Docks menu. Each panel is rendered by WebKitGTK." +PanelName="Panel name" +AddPanel="Add" +RemovePanel="Remove" +PanelFieldsRequired="Every panel must have a name and URL." +PanelNamesUnique="Panel names must be unique." +PanelLoading="Loading web panel..." +PanelRendererError="Unable to start the WebKit panel renderer." +PanelRendererMissing="The WebKit renderer helper is not installed." URL="URL or local HTML file" Width="Width" Height="Height" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index bd08fdf..abc3907 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -1,4 +1,14 @@ WebKitBrowserSource="Браузер WebKit (без CEF)" +CustomBrowserDocks="Пользовательские web-панели..." +CustomBrowserDocksDescription="Добавьте web-панели в меню «Док-панели» OBS. Страницы рендерятся через WebKitGTK." +PanelName="Название панели" +AddPanel="Добавить" +RemovePanel="Удалить" +PanelFieldsRequired="Для каждой панели укажите название и URL." +PanelNamesUnique="Названия панелей не должны повторяться." +PanelLoading="Загрузка web-панели..." +PanelRendererError="Не удалось запустить WebKit-рендерер панели." +PanelRendererMissing="Вспомогательный WebKit-рендерер не установлен." URL="URL или локальный HTML-файл" Width="Ширина" Height="Высота" diff --git a/scripts/install-deps-alpine.sh b/scripts/install-deps-alpine.sh index aeea294..d4a5966 100755 --- a/scripts/install-deps-alpine.sh +++ b/scripts/install-deps-alpine.sh @@ -15,6 +15,7 @@ fi $ROOT apk add \ build-base cmake samurai pkgconf \ obs-studio obs-studio-dev \ + qt6-qtbase-dev \ gtk+3.0 gtk+3.0-dev \ webkit2gtk-4.1 webkit2gtk-4.1-dev \ libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \ diff --git a/src/docks.cpp b/src/docks.cpp new file mode 100644 index 0000000..322514b --- /dev/null +++ b/src/docks.cpp @@ -0,0 +1,822 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "protocol.h" + +extern "C" { +extern char **environ; +const char *owb_module_text(const char *key); +} + +namespace { + +constexpr const char *kSaveKey = "obs-webkit-browser-docks"; +constexpr const char *kDockPrefix = "obs-webkit-browser-dock-"; + +QString text(const char *key) +{ + return QString::fromUtf8(owb_module_text(key)); +} + +bool writeAll(int fd, const void *data, size_t size) +{ + auto cursor = static_cast(data); + while (size > 0) { + ssize_t written = send(fd, cursor, size, MSG_NOSIGNAL); + if (written < 0 && errno == EINTR) + continue; + if (written <= 0) + return false; + cursor += written; + size -= static_cast(written); + } + return true; +} + +void closeFd(int &fd) +{ + if (fd >= 0) + close(fd); + fd = -1; +} + +void closeOnExec(int fd) +{ + int flags = fcntl(fd, F_GETFD); + if (flags >= 0) + fcntl(fd, F_SETFD, flags | FD_CLOEXEC); +} + +void nonBlocking(int fd) +{ + int flags = fcntl(fd, F_GETFL); + if (flags >= 0) + fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +struct DockDefinition { + QString id; + QString title; + QString url; +}; + +class WebPanel final : public QWidget { +public: + explicit WebPanel(QString url, QWidget *parent = nullptr) : QWidget(parent), url_(std::move(url)) + { + setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); + setMinimumSize(80, 60); + resizeTimer_.setSingleShot(true); + resizeTimer_.setInterval(250); + connect(&resizeTimer_, &QTimer::timeout, this, [this] { + if (isVisible()) + restartRenderer(); + }); + } + + ~WebPanel() override { stopRenderer(); } + + void setUrl(const QString &url) + { + if (url_ == url) + return; + url_ = url; + if (isVisible()) + restartRenderer(); + } + + void setDockTitle(const QString &title) + { + for (QWidget *ancestor = parentWidget(); ancestor; ancestor = ancestor->parentWidget()) { + if (auto *dock = qobject_cast(ancestor)) { + dock->setWindowTitle(title); + break; + } + } + } + +protected: + bool event(QEvent *event) override + { + const auto dockClosed = static_cast(QEvent::User + QEvent::Close); + if (event->type() == dockClosed) + stopRenderer(); + return QWidget::event(event); + } + + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + painter.fillRect(rect(), palette().color(QPalette::Base)); + if (!frame_.isNull()) { + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawImage(rect(), frame_); + } else if (!status_.isEmpty()) { + painter.setPen(palette().color(QPalette::Text)); + painter.drawText(rect().adjusted(16, 16, -16, -16), Qt::AlignCenter | Qt::TextWordWrap, + status_); + } + } + + void showEvent(QShowEvent *event) override + { + QWidget::showEvent(event); + if (pid_ <= 0) + QTimer::singleShot(0, this, [this] { startRenderer(); }); + } + + void hideEvent(QHideEvent *event) override + { + resizeTimer_.stop(); + stopRenderer(); + QWidget::hideEvent(event); + } + + void resizeEvent(QResizeEvent *event) override + { + QWidget::resizeEvent(event); + if (pid_ > 0) + resizeTimer_.start(); + } + + void mouseMoveEvent(QMouseEvent *event) override + { + auto [x, y] = rendererPosition(event->position()); + owb_command command{}; + command.type = OWB_MOUSE_MOVE; + command.x = x; + command.y = y; + command.modifiers = interactionModifiers(event->modifiers(), event->buttons()); + sendCommand(command); + } + + void leaveEvent(QEvent *event) override + { + owb_command command{}; + command.type = OWB_MOUSE_MOVE; + command.value1 = 1; + sendCommand(command); + QWidget::leaveEvent(event); + } + + void mousePressEvent(QMouseEvent *event) override + { + setFocus(Qt::MouseFocusReason); + sendMouseButton(event, false); + } + + void mouseReleaseEvent(QMouseEvent *event) override { sendMouseButton(event, true); } + + void wheelEvent(QWheelEvent *event) override + { + auto [x, y] = rendererPosition(event->position()); + owb_command command{}; + command.type = OWB_MOUSE_WHEEL; + command.x = x; + command.y = y; + command.value1 = event->angleDelta().x(); + command.value2 = event->angleDelta().y(); + command.modifiers = interactionModifiers(event->modifiers(), event->buttons()); + sendCommand(command); + event->accept(); + } + + void focusInEvent(QFocusEvent *event) override + { + owb_command command{}; + command.type = OWB_FOCUS; + command.value1 = 1; + sendCommand(command); + QWidget::focusInEvent(event); + } + + void focusOutEvent(QFocusEvent *event) override + { + owb_command command{}; + command.type = OWB_FOCUS; + sendCommand(command); + QWidget::focusOutEvent(event); + } + + void keyPressEvent(QKeyEvent *event) override { sendKey(event, false); } + void keyReleaseEvent(QKeyEvent *event) override { sendKey(event, true); } + + void contextMenuEvent(QContextMenuEvent *event) override + { + QMenu menu(this); + QAction *reload = menu.addAction(text("Reload")); + if (menu.exec(event->globalPos()) == reload) { + owb_command command{}; + command.type = OWB_RELOAD; + sendCommand(command); + } + } + +private: + std::pair rendererPosition(const QPointF &position) const + { + const double sx = width() > 0 ? static_cast(renderWidth_) / width() : 1.0; + const double sy = height() > 0 ? static_cast(renderHeight_) / height() : 1.0; + int x = std::clamp(static_cast(position.x() * sx), 0, std::max(0, renderWidth_ - 1)); + int y = std::clamp(static_cast(position.y() * sy), 0, std::max(0, renderHeight_ - 1)); + return {x, y}; + } + + static uint32_t interactionModifiers(Qt::KeyboardModifiers keys, Qt::MouseButtons buttons) + { + uint32_t result = 0; + if (keys & Qt::ShiftModifier) + result |= INTERACT_SHIFT_KEY; + if (keys & Qt::ControlModifier) + result |= INTERACT_CONTROL_KEY; + if (keys & Qt::AltModifier) + result |= INTERACT_ALT_KEY; + if (keys & Qt::MetaModifier) + result |= INTERACT_COMMAND_KEY; + if (buttons & Qt::LeftButton) + result |= INTERACT_MOUSE_LEFT; + if (buttons & Qt::MiddleButton) + result |= INTERACT_MOUSE_MIDDLE; + if (buttons & Qt::RightButton) + result |= INTERACT_MOUSE_RIGHT; + return result; + } + + void sendMouseButton(QMouseEvent *event, bool released) + { + auto [x, y] = rendererPosition(event->position()); + owb_command command{}; + command.type = OWB_MOUSE_CLICK; + command.x = x; + command.y = y; + command.value1 = event->button() == Qt::MiddleButton ? MOUSE_MIDDLE + : event->button() == Qt::RightButton ? MOUSE_RIGHT + : MOUSE_LEFT; + command.value2 = released; + command.text_length = static_cast(event->type() == QEvent::MouseButtonDblClick ? 2 : 1); + command.modifiers = interactionModifiers(event->modifiers(), event->buttons()); + sendCommand(command); + event->accept(); + } + + void sendKey(QKeyEvent *event, bool released) + { + owb_command command{}; + command.type = OWB_KEY; + command.value1 = released; + command.value2 = static_cast(event->nativeVirtualKey()); + command.modifiers = interactionModifiers(event->modifiers(), {}); + QByteArray utf8 = event->text().toUtf8(); + command.text_length = static_cast(std::min(utf8.size(), sizeof(command.text))); + memcpy(command.text, utf8.constData(), command.text_length); + sendCommand(command); + event->accept(); + } + + void sendCommand(owb_command &command) + { + if (controlFd_ < 0) + return; + command.magic = OWB_COMMAND_MAGIC; + writeAll(controlFd_, &command, sizeof(command)); + } + + bool sendConfiguration() + { + QByteArray url = url_.trimmed().toUtf8(); + if (url.isEmpty()) + url = "about:blank"; + owb_config_header header{}; + header.magic = OWB_CONFIG_MAGIC; + header.version = OWB_PROTOCOL_VERSION; + header.width = static_cast(renderWidth_); + header.height = static_cast(renderHeight_); + header.fps = 30; + header.transparent = 0; + header.hardware_acceleration = 1; + header.url_length = static_cast(url.size()); + return writeAll(controlFd_, &header, sizeof(header)) && + writeAll(controlFd_, url.constData(), static_cast(url.size())); + } + + void startRenderer() + { + if (pid_ > 0 || !isVisible()) + return; + status_ = text("PanelLoading"); + frame_ = QImage(); + update(); + + const double scale = devicePixelRatioF(); + renderWidth_ = std::clamp(static_cast(width() * scale), 2, 8192); + renderHeight_ = std::clamp(static_cast(height() * scale), 2, 8192); + sharedSize_ = owb_shared_size(renderWidth_, renderHeight_); + sharedFd_ = memfd_create("obs-webkit-browser-dock", MFD_CLOEXEC); + if (sharedFd_ < 0 || ftruncate(sharedFd_, static_cast(sharedSize_)) != 0) + return startFailed(text("PanelRendererError")); + shared_ = static_cast( + mmap(nullptr, sharedSize_, PROT_READ | PROT_WRITE, MAP_SHARED, sharedFd_, 0)); + if (shared_ == MAP_FAILED) { + shared_ = nullptr; + return startFailed(text("PanelRendererError")); + } + memset(shared_, 0, sizeof(*shared_)); + shared_->magic = OWB_SHARED_MAGIC; + shared_->version = OWB_PROTOCOL_VERSION; + shared_->width = static_cast(renderWidth_); + shared_->height = static_cast(renderHeight_); + shared_->stride = static_cast(renderWidth_ * 4); + shared_->slot_count = OWB_SHARED_FRAME_SLOTS; + + int control[2] = {-1, -1}; + int frames[2] = {-1, -1}; + int logs[2] = {-1, -1}; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) != 0 || pipe(frames) != 0 || pipe(logs) != 0) { + for (int fd : control) + if (fd >= 0) + ::close(fd); + for (int fd : frames) + if (fd >= 0) + ::close(fd); + for (int fd : logs) + if (fd >= 0) + ::close(fd); + return startFailed(text("PanelRendererError")); + } + for (int fd : control) + closeOnExec(fd); + for (int fd : frames) + closeOnExec(fd); + for (int fd : logs) + closeOnExec(fd); + + char *helper = obs_module_file("obs-webkit-renderer"); + if (!helper || access(helper, X_OK) != 0) { + for (int fd : control) + ::close(fd); + for (int fd : frames) + ::close(fd); + for (int fd : logs) + ::close(fd); + bfree(helper); + return startFailed(text("PanelRendererMissing")); + } + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + posix_spawn_file_actions_init(&actions); + posix_spawnattr_init(&attributes); + sigset_t defaults; + sigemptyset(&defaults); + sigaddset(&defaults, SIGTERM); + sigaddset(&defaults, SIGINT); + sigaddset(&defaults, SIGPIPE); + posix_spawnattr_setsigdefault(&attributes, &defaults); + posix_spawnattr_setpgroup(&attributes, 0); + posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETPGROUP); + posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO); + posix_spawn_file_actions_adddup2(&actions, frames[1], STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&actions, logs[1], STDERR_FILENO); + posix_spawn_file_actions_adddup2(&actions, sharedFd_, OWB_SHARED_FRAME_FD); + posix_spawn_file_actions_addclose(&actions, control[0]); + posix_spawn_file_actions_addclose(&actions, frames[0]); + posix_spawn_file_actions_addclose(&actions, logs[0]); + char *argv[] = {helper, nullptr}; + pid_t child = 0; + int spawnError = posix_spawn(&child, helper, &actions, &attributes, argv, environ); + posix_spawn_file_actions_destroy(&actions); + posix_spawnattr_destroy(&attributes); + bfree(helper); + ::close(control[1]); + ::close(frames[1]); + ::close(logs[1]); + if (spawnError != 0) { + ::close(control[0]); + ::close(frames[0]); + ::close(logs[0]); + return startFailed(text("PanelRendererError")); + } + + pid_ = child; + controlFd_ = control[0]; + frameFd_ = frames[0]; + logFd_ = logs[0]; + nonBlocking(frameFd_); + nonBlocking(logFd_); + if (!sendConfiguration()) { + stopRenderer(); + return startFailed(text("PanelRendererError")); + } + frameNotifier_ = new QSocketNotifier(frameFd_, QSocketNotifier::Read, this); + connect(frameNotifier_, &QSocketNotifier::activated, this, [this] { readFrames(); }); + logNotifier_ = new QSocketNotifier(logFd_, QSocketNotifier::Read, this); + connect(logNotifier_, &QSocketNotifier::activated, this, [this] { readLogs(); }); + } + + void startFailed(const QString &message) + { + status_ = message; + stopRenderer(); + update(); + } + + void restartRenderer() + { + stopRenderer(); + startRenderer(); + } + + void stopRenderer() + { + delete frameNotifier_; + frameNotifier_ = nullptr; + delete logNotifier_; + logNotifier_ = nullptr; + if (controlFd_ >= 0) + shutdown(controlFd_, SHUT_RDWR); + closeFd(controlFd_); + closeFd(frameFd_); + closeFd(logFd_); + if (pid_ > 0) { + kill(-pid_, SIGTERM); + for (unsigned attempt = 0; attempt < 50; ++attempt) { + pid_t result = waitpid(pid_, nullptr, WNOHANG); + if (result == pid_ || (result < 0 && errno == ECHILD)) { + pid_ = 0; + break; + } + usleep(10000); + } + if (pid_ > 0) { + kill(-pid_, SIGKILL); + while (waitpid(pid_, nullptr, 0) < 0 && errno == EINTR) { + } + pid_ = 0; + } + } + if (shared_) + munmap(shared_, sharedSize_); + shared_ = nullptr; + sharedSize_ = 0; + closeFd(sharedFd_); + logBuffer_.clear(); + } + + void readFrames() + { + uint8_t frameSlots[128]; + uint8_t latest = OWB_SHARED_FRAME_SLOTS; + for (;;) { + ssize_t count = read(frameFd_, frameSlots, sizeof(frameSlots)); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + break; + for (ssize_t i = 0; i < count; ++i) + if (frameSlots[i] < OWB_SHARED_FRAME_SLOTS) + latest = frameSlots[i]; + } + if (!shared_ || latest >= OWB_SHARED_FRAME_SLOTS) + return; + for (unsigned attempt = 0; attempt < 3; ++attempt) { + uint64_t before = __atomic_load_n(&shared_->sequence[latest], __ATOMIC_ACQUIRE); + if (before & 1u) + continue; + QImage view(owb_shared_slot(shared_, latest), renderWidth_, renderHeight_, renderWidth_ * 4, + QImage::Format_ARGB32); + QImage copy = view.copy(); + uint64_t after = __atomic_load_n(&shared_->sequence[latest], __ATOMIC_ACQUIRE); + if (before == after && !(after & 1u)) { + frame_ = std::move(copy); + status_.clear(); + update(); + break; + } + } + } + + void readLogs() + { + char bytes[1024]; + for (;;) { + ssize_t count = read(logFd_, bytes, sizeof(bytes)); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + break; + logBuffer_.append(bytes, static_cast(count)); + } + for (;;) { + qsizetype newline = logBuffer_.indexOf('\n'); + if (newline < 0) + break; + QByteArray line = logBuffer_.left(newline).trimmed(); + logBuffer_.remove(0, newline + 1); + if (!line.isEmpty()) + blog(LOG_INFO, "[obs-webkit-browser dock] %s", line.constData()); + } + } + + QString url_; + QString status_; + QImage frame_; + QTimer resizeTimer_; + pid_t pid_ = 0; + int controlFd_ = -1; + int frameFd_ = -1; + int logFd_ = -1; + int sharedFd_ = -1; + owb_shared_frames *shared_ = nullptr; + size_t sharedSize_ = 0; + int renderWidth_ = 2; + int renderHeight_ = 2; + QSocketNotifier *frameNotifier_ = nullptr; + QSocketNotifier *logNotifier_ = nullptr; + QByteArray logBuffer_; +}; + +class DockManager final { +public: + DockManager() + { + obs_frontend_add_save_callback(saveCallback, this); + obs_frontend_add_preload_callback(preloadCallback, this); + obs_frontend_add_event_callback(eventCallback, this); + setupFrontend(); + } + + ~DockManager() + { + /* OBS clears frontend callbacks and owns the dock wrappers before + * unloading modules during shutdown. Calling frontend removal APIs + * from obs_module_unload is therefore too late. */ + panels_.clear(); + if (menuAction_) + delete menuAction_; + } + + void setupFrontend() + { + if (menuAction_ || !QApplication::instance()) + return; + auto *main = static_cast(obs_frontend_get_main_window()); + if (!main) + return; + menuAction_ = new QAction(text("CustomBrowserDocks"), main); + QObject::connect(menuAction_, &QAction::triggered, main, [this] { showEditor(); }); + if (QMenu *docksMenu = main->findChild("menuDocks")) + docksMenu->addAction(menuAction_); + else + main->menuBar()->addAction(menuAction_); + reconcile(); + } + +private: + static QString dockId(const QString &id) { return QString::fromUtf8(kDockPrefix) + id; } + + static void eventCallback(obs_frontend_event event, void *opaque) + { + auto *manager = static_cast(opaque); + if (event == OBS_FRONTEND_EVENT_FINISHED_LOADING) + manager->setupFrontend(); + else if (event == OBS_FRONTEND_EVENT_EXIT) { + manager->panels_.clear(); + manager->menuAction_ = nullptr; + } + } + + static void saveCallback(obs_data_t *saveData, bool saving, void *opaque) + { + static_cast(opaque)->saveLoad(saveData, saving); + } + + static void preloadCallback(obs_data_t *saveData, bool saving, void *opaque) + { + if (!saving) + static_cast(opaque)->saveLoad(saveData, false); + } + + void saveLoad(obs_data_t *saveData, bool saving) + { + if (saving) { + obs_data_array_t *array = obs_data_array_create(); + for (const DockDefinition &definition : definitions_) { + obs_data_t *item = obs_data_create(); + obs_data_set_string(item, "id", definition.id.toUtf8().constData()); + obs_data_set_string(item, "title", definition.title.toUtf8().constData()); + obs_data_set_string(item, "url", definition.url.toUtf8().constData()); + obs_data_array_push_back(array, item); + obs_data_release(item); + } + obs_data_set_array(saveData, kSaveKey, array); + obs_data_array_release(array); + return; + } + + definitions_.clear(); + obs_data_array_t *array = obs_data_get_array(saveData, kSaveKey); + if (array) { + size_t count = obs_data_array_count(array); + for (size_t index = 0; index < count; ++index) { + obs_data_t *item = obs_data_array_item(array, index); + DockDefinition definition{ + QString::fromUtf8(obs_data_get_string(item, "id")), + QString::fromUtf8(obs_data_get_string(item, "title")), + QString::fromUtf8(obs_data_get_string(item, "url")), + }; + if (definition.id.isEmpty()) + definition.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + if (!definition.title.trimmed().isEmpty() && !definition.url.trimmed().isEmpty()) + definitions_.push_back(std::move(definition)); + obs_data_release(item); + } + obs_data_array_release(array); + } + reconcile(); + } + + void reconcile() + { + if (!menuAction_) + return; + QHash wanted; + for (const DockDefinition &definition : definitions_) + wanted.insert(definition.id, definition); + for (auto it = panels_.begin(); it != panels_.end();) { + if (!wanted.contains(it.key())) { + QByteArray id = dockId(it.key()).toUtf8(); + obs_frontend_remove_dock(id.constData()); + it = panels_.erase(it); + } else { + ++it; + } + } + for (const DockDefinition &definition : definitions_) { + if (panels_.contains(definition.id) && panels_[definition.id]) { + panels_[definition.id]->setUrl(definition.url); + panels_[definition.id]->setDockTitle(definition.title); + continue; + } + auto *panel = new WebPanel(definition.url); + QByteArray id = dockId(definition.id).toUtf8(); + QByteArray title = definition.title.toUtf8(); + if (obs_frontend_add_dock_by_id(id.constData(), title.constData(), panel)) + panels_.insert(definition.id, panel); + else + delete panel; + } + } + + void showEditor() + { + auto *main = static_cast(obs_frontend_get_main_window()); + QDialog dialog(main); + dialog.setWindowTitle(text("CustomBrowserDocks")); + dialog.resize(720, 360); + auto *layout = new QVBoxLayout(&dialog); + layout->addWidget(new QLabel(text("CustomBrowserDocksDescription"), &dialog)); + auto *table = new QTableWidget(0, 2, &dialog); + table->setHorizontalHeaderLabels({text("PanelName"), text("URL")}); + table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::ExtendedSelection); + layout->addWidget(table); + + auto appendRow = [table](const DockDefinition &definition) { + int row = table->rowCount(); + table->insertRow(row); + auto *title = new QTableWidgetItem(definition.title); + title->setData(Qt::UserRole, definition.id); + table->setItem(row, 0, title); + table->setItem(row, 1, new QTableWidgetItem(definition.url)); + }; + for (const DockDefinition &definition : definitions_) + appendRow(definition); + + auto *rowButtons = new QHBoxLayout; + auto *add = new QPushButton(text("AddPanel"), &dialog); + auto *remove = new QPushButton(text("RemovePanel"), &dialog); + rowButtons->addWidget(add); + rowButtons->addWidget(remove); + rowButtons->addStretch(); + layout->addLayout(rowButtons); + QObject::connect(add, &QPushButton::clicked, table, [table, appendRow] { + appendRow({QUuid::createUuid().toString(QUuid::WithoutBraces), QString(), "https://"}); + int row = table->rowCount() - 1; + table->setCurrentCell(row, 0); + table->editItem(table->item(row, 0)); + }); + QObject::connect(remove, &QPushButton::clicked, table, [table] { + QSet rows; + for (QTableWidgetItem *item : table->selectedItems()) + rows.insert(item->row()); + QList sorted = rows.values(); + std::sort(sorted.begin(), sorted.end(), std::greater()); + for (int row : sorted) + table->removeRow(row); + }); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Apply | QDialogButtonBox::Close, + Qt::Horizontal, &dialog); + layout->addWidget(buttons); + QObject::connect(buttons->button(QDialogButtonBox::Close), &QPushButton::clicked, &dialog, + &QDialog::reject); + QObject::connect(buttons->button(QDialogButtonBox::Apply), &QPushButton::clicked, &dialog, + [this, table, &dialog] { + QVector updated; + QSet titles; + for (int row = 0; row < table->rowCount(); ++row) { + QString title = table->item(row, 0) ? table->item(row, 0)->text().trimmed() : QString(); + QString url = table->item(row, 1) ? table->item(row, 1)->text().trimmed() : QString(); + if (title.isEmpty() || url.isEmpty()) { + QMessageBox::warning(&dialog, text("CustomBrowserDocks"), text("PanelFieldsRequired")); + return; + } + if (titles.contains(title)) { + QMessageBox::warning(&dialog, text("CustomBrowserDocks"), text("PanelNamesUnique")); + return; + } + titles.insert(title); + QString id = table->item(row, 0)->data(Qt::UserRole).toString(); + if (id.isEmpty()) + id = QUuid::createUuid().toString(QUuid::WithoutBraces); + updated.push_back({id, title, url}); + } + definitions_ = std::move(updated); + reconcile(); + obs_frontend_save(); + }); + dialog.exec(); + } + + QVector definitions_; + QHash> panels_; + QPointer menuAction_; +}; + +DockManager *manager = nullptr; + +} // namespace + +extern "C" void owb_docks_initialize(void) +{ + if (!manager && QApplication::instance()) + manager = new DockManager; +} + +extern "C" void owb_docks_shutdown(void) +{ + delete manager; + manager = nullptr; +} diff --git a/src/plugin.c b/src/plugin.c index e0d7d33..fb2a552 100644 --- a/src/plugin.c +++ b/src/plugin.c @@ -29,6 +29,14 @@ MODULE_EXPORT const char *obs_module_description(void) return "WebKitGTK browser source for OBS (no CEF)"; } +void owb_docks_initialize(void); +void owb_docks_shutdown(void); + +const char *owb_module_text(const char *key) +{ + return obs_module_text(key); +} + extern char **environ; struct browser_source; @@ -295,7 +303,7 @@ static bool start_renderer_locked(struct browser_source *browser) shared->width = browser->width; shared->height = browser->height; shared->stride = browser->width * 4; - shared->slots = OWB_SHARED_FRAME_SLOTS; + shared->slot_count = OWB_SHARED_FRAME_SLOTS; if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) != 0 || pipe(frames) != 0 || pipe(logs) != 0) { blog(LOG_ERROR, "[obs-webkit-browser] IPC setup failed: %s", strerror(errno)); goto fail; @@ -602,6 +610,12 @@ static struct obs_source_info browser_info = { bool obs_module_load(void) { obs_register_source(&browser_info); + owb_docks_initialize(); blog(LOG_INFO, "[obs-webkit-browser] Loaded WebKitGTK browser source"); return true; } + +void obs_module_unload(void) +{ + owb_docks_shutdown(); +} diff --git a/src/protocol.h b/src/protocol.h index dbb6dae..16513cc 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -29,9 +29,9 @@ struct owb_shared_frames { uint32_t width; uint32_t height; uint32_t stride; - uint32_t slots; + uint32_t slot_count; uint64_t sequence[OWB_SHARED_FRAME_SLOTS]; - uint8_t pixels[]; + uint8_t pixels[1]; }; static inline size_t owb_frame_size(uint32_t width, uint32_t height) @@ -41,7 +41,7 @@ static inline size_t owb_frame_size(uint32_t width, uint32_t height) static inline size_t owb_shared_size(uint32_t width, uint32_t height) { - return sizeof(struct owb_shared_frames) + + return offsetof(struct owb_shared_frames, pixels) + owb_frame_size(width, height) * OWB_SHARED_FRAME_SLOTS; } diff --git a/src/renderer.c b/src/renderer.c index 1c17fa8..7374883 100644 --- a/src/renderer.c +++ b/src/renderer.c @@ -173,7 +173,7 @@ static bool map_shared_frames(struct renderer *renderer) renderer->shared->width == renderer->config.width && renderer->shared->height == renderer->config.height && renderer->shared->stride == renderer->config.width * 4 && - renderer->shared->slots == OWB_SHARED_FRAME_SLOTS; + renderer->shared->slot_count == OWB_SHARED_FRAME_SLOTS; } static bool initialize_x11_capture(struct renderer *renderer) @@ -386,6 +386,16 @@ static GdkModifierType modifiers_from_obs(uint32_t modifiers) return state; } +static void set_event_device(GdkEvent *event, GdkWindow *window, bool keyboard) +{ + GdkDisplay *display = gdk_window_get_display(window); + GdkSeat *seat = display ? gdk_display_get_default_seat(display) : NULL; + GdkDevice *device = seat ? (keyboard ? gdk_seat_get_keyboard(seat) : gdk_seat_get_pointer(seat)) + : NULL; + if (device) + gdk_event_set_device(event, device); +} + static void dispatch_command(struct renderer *renderer, const struct owb_command *command) { if (command->magic != OWB_COMMAND_MAGIC) @@ -405,6 +415,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command if (command->type == OWB_MOUSE_MOVE) { if (command->value1) { GdkEvent *event = gdk_event_new(GDK_LEAVE_NOTIFY); + set_event_device(event, window, false); event->crossing.window = g_object_ref(window); event->crossing.send_event = TRUE; event->crossing.time = GDK_CURRENT_TIME; @@ -418,6 +429,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command return; } GdkEvent *event = gdk_event_new(GDK_MOTION_NOTIFY); + set_event_device(event, window, false); event->motion.window = g_object_ref(window); event->motion.send_event = TRUE; event->motion.time = GDK_CURRENT_TIME; @@ -434,6 +446,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command : command->text_length >= 3 ? GDK_3BUTTON_PRESS : GDK_BUTTON_PRESS; GdkEvent *event = gdk_event_new(type); + set_event_device(event, window, false); event->button.window = g_object_ref(window); event->button.send_event = TRUE; event->button.time = GDK_CURRENT_TIME; @@ -447,6 +460,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command } if (command->type == OWB_MOUSE_WHEEL) { GdkEvent *event = gdk_event_new(GDK_SCROLL); + set_event_device(event, window, false); event->scroll.window = g_object_ref(window); event->scroll.send_event = TRUE; event->scroll.time = GDK_CURRENT_TIME; @@ -462,6 +476,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command } if (command->type == OWB_KEY) { GdkEvent *event = gdk_event_new(command->value1 ? GDK_KEY_RELEASE : GDK_KEY_PRESS); + set_event_device(event, window, true); event->key.window = g_object_ref(window); event->key.send_event = TRUE; event->key.time = GDK_CURRENT_TIME; diff --git a/tests/protocol-test.c b/tests/protocol-test.c index 4843575..0735a4d 100644 --- a/tests/protocol-test.c +++ b/tests/protocol-test.c @@ -7,7 +7,8 @@ int main(void) { assert(sizeof(struct owb_config_header) == 36); assert(sizeof(struct owb_command) == 100); - assert(sizeof(struct owb_shared_frames) == 40); + assert(offsetof(struct owb_shared_frames, pixels) == 40); + assert(owb_shared_size(2, 2) == 72); assert(offsetof(struct owb_command, text) == 36); assert(OWB_CONFIG_MAGIC != OWB_COMMAND_MAGIC); return 0; diff --git a/tests/renderer-fps-test.c b/tests/renderer-fps-test.c index 8c07dae..9e21589 100644 --- a/tests/renderer-fps-test.c +++ b/tests/renderer-fps-test.c @@ -69,7 +69,7 @@ int main(int argc, char **argv) shared->width = width; shared->height = height; shared->stride = width * 4; - shared->slots = OWB_SHARED_FRAME_SLOTS; + shared->slot_count = OWB_SHARED_FRAME_SLOTS; posix_spawn_file_actions_t actions; posix_spawn_file_actions_init(&actions); posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO); diff --git a/tests/renderer-smoke-test.c b/tests/renderer-smoke-test.c index b2c14ad..5bc29d2 100644 --- a/tests/renderer-smoke-test.c +++ b/tests/renderer-smoke-test.c @@ -84,7 +84,7 @@ int main(int argc, char **argv) shared->width = width; shared->height = height; shared->stride = width * 4; - shared->slots = OWB_SHARED_FRAME_SLOTS; + shared->slot_count = OWB_SHARED_FRAME_SLOTS; posix_spawn_file_actions_t actions; posix_spawn_file_actions_init(&actions); posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO);