#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; }