8 Commits

Author SHA1 Message Date
3d7efde9e9 Update README.md 2026-07-20 12:00:56 +05:00
b9309e2326 Fix transparency? 2026-07-20 11:40:45 +05:00
eb13c87dd3 Add sound capture 2026-07-20 10:57:34 +05:00
07bd12ee9a Add scale support 2026-07-20 10:23:57 +05:00
53161f3f2c update build 2026-07-18 12:30:55 +05:00
8b386b3ab2 add sync tabs + refresh button 2026-07-18 12:09:17 +05:00
3652cfab3c add icon 2026-07-18 11:36:44 +05:00
d3872d60ef web docks 2026-07-18 11:33:47 +05:00
18 changed files with 2453 additions and 170 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
/build/ /build/
/build-*/
/dist/
*.o *.o
*.so *.so
*.swp *.swp

View File

@@ -1,25 +1,40 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(obs-webkit-browser VERSION 0.1.0 LANGUAGES C) project(obs-webkit-browser VERSION 1.2.1 LANGUAGES C CXX)
include(GNUInstallDirs) include(GNUInstallDirs)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
pkg_check_modules(OBS REQUIRED IMPORTED_TARGET libobs) 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(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1) pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1)
pkg_check_modules(X11_CAPTURE REQUIRED IMPORTED_TARGET x11 xcomposite xdamage xext) pkg_check_modules(X11_CAPTURE REQUIRED IMPORTED_TARGET x11 xcomposite xdamage xext)
pkg_check_modules(PULSE REQUIRED IMPORTED_TARGET libpulse libpulse-simple)
add_library(obs-webkit-browser MODULE add_library(obs-webkit-browser MODULE
src/plugin.c src/plugin.c
src/docks.cpp
src/protocol.h src/protocol.h
src/pulse-capture.c
src/pulse-capture.h
) )
target_compile_features(obs-webkit-browser PRIVATE c_std_11) 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_definitions(obs-webkit-browser PRIVATE _GNU_SOURCE)
target_compile_options(obs-webkit-browser PRIVATE -Wall -Wextra -Wpedantic) 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
PkgConfig::PULSE
Threads::Threads
)
set_target_properties(obs-webkit-browser PROPERTIES PREFIX "") set_target_properties(obs-webkit-browser PROPERTIES PREFIX "")
add_executable(obs-webkit-renderer add_executable(obs-webkit-renderer
@@ -68,6 +83,10 @@ if(BUILD_TESTING)
COMMAND renderer-smoke-test $<TARGET_FILE:obs-webkit-renderer> opaque) COMMAND renderer-smoke-test $<TARGET_FILE:obs-webkit-renderer> opaque)
set_tests_properties(renderer-opaque-smoke-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77) set_tests_properties(renderer-opaque-smoke-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_test(NAME renderer-zoom-smoke-test
COMMAND renderer-smoke-test $<TARGET_FILE:obs-webkit-renderer> zoom)
set_tests_properties(renderer-zoom-smoke-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_executable(renderer-fps-test tests/renderer-fps-test.c) add_executable(renderer-fps-test tests/renderer-fps-test.c)
target_compile_features(renderer-fps-test PRIVATE c_std_11) target_compile_features(renderer-fps-test PRIVATE c_std_11)
target_compile_definitions(renderer-fps-test PRIVATE _GNU_SOURCE) target_compile_definitions(renderer-fps-test PRIVATE _GNU_SOURCE)
@@ -76,6 +95,14 @@ if(BUILD_TESTING)
COMMAND renderer-fps-test $<TARGET_FILE:obs-webkit-renderer>) COMMAND renderer-fps-test $<TARGET_FILE:obs-webkit-renderer>)
set_tests_properties(blur-transition-fps-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77) set_tests_properties(blur-transition-fps-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_executable(shared-profile-test tests/shared-profile-test.c)
target_compile_features(shared-profile-test PRIVATE c_std_11)
target_compile_definitions(shared-profile-test PRIVATE _GNU_SOURCE)
target_include_directories(shared-profile-test PRIVATE src)
add_test(NAME shared-profile-test
COMMAND shared-profile-test $<TARGET_FILE:obs-webkit-renderer>)
set_tests_properties(shared-profile-test PROPERTIES TIMEOUT 30 SKIP_RETURN_CODE 77)
add_executable(module-load-test tests/module-load-test.c) add_executable(module-load-test tests/module-load-test.c)
target_compile_features(module-load-test PRIVATE c_std_11) target_compile_features(module-load-test PRIVATE c_std_11)
target_link_libraries(module-load-test PRIVATE PkgConfig::OBS) target_link_libraries(module-load-test PRIVATE PkgConfig::OBS)
@@ -83,4 +110,14 @@ if(BUILD_TESTING)
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data" $<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
$<TARGET_FILE:obs-webkit-renderer>) $<TARGET_FILE:obs-webkit-renderer>)
set_tests_properties(module-load-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77) set_tests_properties(module-load-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_test(NAME module-audio-test COMMAND module-load-test
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
$<TARGET_FILE:obs-webkit-renderer> audio-test)
set_tests_properties(module-audio-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
add_test(NAME module-alpha-test COMMAND module-load-test
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
$<TARGET_FILE:obs-webkit-renderer> alpha-test)
set_tests_properties(module-alpha-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
endif() endif()

57
Makefile Normal file
View File

@@ -0,0 +1,57 @@
PROJECT_NAME := obs-webkit-browser
VERSION := $(shell sed -n '/^project.* VERSION / { s/^project.* VERSION //; s/ .*//; p; }' CMakeLists.txt)
ARCH := $(shell uname -m)
BUILD_DIR ?= build-release
DIST_DIR ?= dist
STAGE_DIR ?= $(BUILD_DIR)/stage
INSTALL_PREFIX ?= /usr
ARCHIVE_NAME := $(PROJECT_NAME)-$(VERSION)-linux-$(ARCH).tar.gz
ARCHIVE_PATH := $(abspath $(DIST_DIR)/$(ARCHIVE_NAME))
PLUGIN_STAGE_DIR := $(STAGE_DIR)/$(PROJECT_NAME)
PLUGIN_BIN_DIR := $(PLUGIN_STAGE_DIR)/bin/64bit
PLUGIN_DATA_DIR := $(PLUGIN_STAGE_DIR)/data
CMAKE ?= $(if $(wildcard /usr/bin/cmake),/usr/bin/cmake,cmake)
CTEST ?= $(if $(wildcard /usr/bin/ctest),/usr/bin/ctest,ctest)
.PHONY: all configure build test install stage archive clean
all: build
configure:
$(CMAKE) -S . -B $(BUILD_DIR) -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=$(INSTALL_PREFIX)
build: configure
$(CMAKE) --build $(BUILD_DIR)
test: build
$(CTEST) --test-dir $(BUILD_DIR) --output-on-failure
install: build
$(CMAKE) --install $(BUILD_DIR) --strip
stage: test
$(CMAKE) -E remove_directory $(STAGE_DIR)
$(CMAKE) -E make_directory $(PLUGIN_BIN_DIR)
$(CMAKE) -E make_directory $(PLUGIN_DATA_DIR)/locale
$(CMAKE) -E copy_if_different \
$(BUILD_DIR)/obs-webkit-browser.so $(PLUGIN_BIN_DIR)/obs-webkit-browser.so
$(CMAKE) -E copy_if_different \
$(BUILD_DIR)/obs-webkit-renderer $(PLUGIN_DATA_DIR)/obs-webkit-renderer
$(CMAKE) -E copy_if_different \
data/locale/en-US.ini data/locale/ru-RU.ini $(PLUGIN_DATA_DIR)/locale/
archive: stage
$(CMAKE) -E make_directory $(DIST_DIR)
cd $(STAGE_DIR) && $(CMAKE) -E tar cfvz $(ARCHIVE_PATH) --format=gnutar $(PROJECT_NAME)
$(CMAKE) -E sha256sum $(ARCHIVE_PATH) >$(ARCHIVE_PATH).sha256
@echo
@echo "Archive: $(ARCHIVE_PATH)"
@echo "SHA-256: $(ARCHIVE_PATH).sha256"
clean:
$(CMAKE) -E remove_directory $(BUILD_DIR)
$(CMAKE) -E remove_directory $(DIST_DIR)

132
README.md
View File

@@ -1,39 +1,62 @@
# OBS WebKit Browser Source (без CEF) # OBS WebKit Browser Source (No CEF)
Нативный input source для OBS Studio на Alpine Linux. Страницы рендерит системный A native browser input source for OBS Studio on Alpine Linux. Pages are rendered
WebKitGTK 4.1 в отдельном процессе; Chromium/CEF и Xvfb в проекте нет. Renderer by the system WebKitGTK 4.1 in a separate process; the project does not use
использует X11/XWayland-сессию OBS и держит WebView в полностью прозрачном Chromium/CEF or Xvfb. The renderer uses the X11/XWayland display inherited from
неинтерактивном нативном окне. Нативная surface нужна WebKitGTK для импорта GPU OBS and keeps its WebView in a fully transparent, non-interactive native window.
DMA-BUF. Кадры передаются в OBS напрямую как BGRA, без FFmpeg и захвата рабочего WebKitGTK needs this native surface to import GPU DMA-BUF buffers. Frames are
стола. sent directly to OBS as BGRA without FFmpeg or desktop capture.
Поддерживается: Features:
- HTTP/HTTPS, `data:` URL и локальные HTML-файлы; - HTTP/HTTPS and `data:` URLs, plus local HTML files;
- прозрачный фон с alpha-каналом; - transparent backgrounds with an alpha channel;
- пользовательский CSS; - custom CSS;
- размер 28192 px и 160 FPS; - 25500% page zoom saved in source or web panel settings;
- аппаратный WebKit compositor через DRM/DMA-BUF (Mesa `radeonsi` на AMD); - optional per-source audio capture as a separate OBS Audio Mixer channel;
- прямой XComposite/XShm-захват скомпозированного GPU-кадра с alpha; - dimensions from 2 to 8192 px and frame rates from 1 to 60 FPS;
- XDamage-пропуск чтения backing pixmap, пока содержимое страницы не меняется; - hardware-accelerated WebKit compositing through DRM/DMA-BUF (Mesa `radeonsi`
- shared-memory транспорт кадров без многомегабайтной записи в pipe; on AMD);
- мышь, прокрутка, фокус и базовый ввод с клавиатуры через окно Interact OBS; - direct XComposite/XShm capture of composited GPU frames with alpha;
- перезагрузка страницы и остановка renderer-процесса у скрытого source. - XDamage-based skipping of backing pixmap reads while page content is unchanged;
- shared-memory frame transport without sending multi-megabyte frames through a pipe;
- mouse, scrolling, focus, and basic keyboard input through the OBS Interact window;
- page reload and optional renderer shutdown while a source is hidden;
- custom web panels in the OBS Docks menu, with URLs stored in the scene collection,
restored on startup, and full interactive input;
- one shared persistent WebKit profile for scene sources and web panels, including
cookies, HTTP cache, localStorage, IndexedDB, service workers, and credentials;
same-origin pages communicate through standard `storage` events and
`BroadcastChannel`, just like browser tabs.
Звук страницы намеренно выключен: добавьте в OBS отдельный Application Audio Capture. Audio capture is disabled by default. Enable **Capture browser audio** in the
Renderer наследует X11/XWayland-дисплей OBS; его нативное окно имеет нулевую opacity, source settings to add its audio as a separate OBS Audio Mixer channel. Capture
пустую input shape и не появляется на рабочем столе. Дополнительный display server не requires a running PulseAudio-compatible server (including PipeWire Pulse) and
запускается. WebKit принудительно включает аппаратный compositor и выбирает доступный the GStreamer `pulsesink`; the dependency installation script installs the
DRM render node; для него нужен доступ к `/dev/dri/renderD*`. required `gst-plugins-good` package.
Для тяжёлого полноэкранного `filter: blur()` всё ещё разумно выбирать реальный размер
виджета вместо рендера 4K/1080p с последующим уменьшением в сцене. Пользовательские
`will-change` и compositor hints сохраняются, но renderer не внедряет их в страницу сам:
WebKit сам распределяет элементы по GPU-слоям.
Это рабочий backend, а не замена всех расширенных возможностей штатного obs-browser All WebViews in one OBS session share a renderer process and a single
(DevTools, cookies API, browser docks и DRM здесь пока отсутствуют). `WebKitWebContext`. Small proxy processes preserve the independent lifetime of
each source or dock and isolate them from OBS. The profile is stored in the
standard user data and cache directories under `obs-webkit-browser/profile` and
survives OBS restarts.
## Сборка на Alpine The renderer inherits the X11/XWayland display from OBS. Its native window has
zero opacity and an empty input shape, so it does not appear on the desktop. No
additional display server is started. WebKit is forced to use its hardware
compositor and selects an available DRM render node, which requires access to
`/dev/dri/renderD*`.
For expensive full-screen `filter: blur()` effects, use the actual widget size
instead of rendering at 4K/1080p and scaling the source down in the scene. Custom
`will-change` declarations and compositor hints are preserved, but the renderer
does not inject them itself; WebKit assigns elements to GPU layers.
This is a working backend, not a replacement for every advanced feature in the
standard obs-browser plugin. DevTools, the cookies API, and DRM are not currently
available.
## Building on Alpine
```sh ```sh
chmod +x scripts/install-deps-alpine.sh chmod +x scripts/install-deps-alpine.sh
@@ -44,13 +67,46 @@ ctest --test-dir build --output-on-failure
doas /usr/bin/cmake --install build --prefix /usr doas /usr/bin/cmake --install build --prefix /usr
``` ```
После установки перезапустите OBS и добавьте источник **Браузер WebKit (без CEF)**. After installation, restart OBS and add a **WebKit Browser** source. To add a
Если OBS установлен в нестандартный prefix, задайте при конфигурации panel, open **Docks → Custom Web Panels...**, enter its name and URL, and click
`OBS_PLUGIN_DIR` и `OBS_PLUGIN_DATA_DIR`. **Apply**. Panels can be shown or hidden through the regular Docks menu; right-click
a page to reload it. If OBS uses a non-standard prefix, set `OBS_PLUGIN_DIR` and
`OBS_PLUGIN_DATA_DIR` during configuration.
## Почему WebKitGTK, а не WPE ## Release Archive
WPE архитектурно подходит лучше всего, но пакет `wpewebkit` отсутствует в актуальном After installing the dependencies, use the dedicated `Makefile` targets:
Alpine edge/3.24 (остались только `libwpe` и backend). WebKitGTK 4.1 есть в текущем
репозитории Alpine, использует тот же WebKit и не зависит от CEF. Renderer изолирован ```sh
от OBS, поэтому падение Web-процесса не должно уронить студию. make build
make test
doas make install
make archive
```
`make build` creates a Release build, `make test` runs the tests,
`doas make install` installs the plugin into `/usr`, and `make archive` builds and
tests the project before creating a package without installing it. The archive
`obs-webkit-browser-<version>-linux-<arch>.tar.gz` and its SHA-256 file are written
to `dist/`. The archive contains no absolute paths, only the user plugin layout
`obs-webkit-browser/{bin/64bit,data}`. Install it directly into the OBS user plugin
directory:
```sh
mkdir -p ~/.config/obs-studio/plugins
tar -xzf dist/obs-webkit-browser-<version>-linux-<arch>.tar.gz \
-C ~/.config/obs-studio/plugins
```
## Why WebKitGTK Instead of WPE?
WPE would be the best architectural fit, but the `wpewebkit` package is not
available in current Alpine edge/3.24; only `libwpe` and the backend remain.
WebKitGTK 4.1 is available in the current Alpine repository, uses the same WebKit,
and does not depend on CEF. The renderer is isolated from OBS, so a Web process
crash should not bring down the studio.
## Donations
- TRC20: `TYxUB8Vey9xh7jS9cPU2R34eT97pAN851v`
- TON: `UQDBYC5TfbQLsgf50xtBaq9jtyhMl4klwJIeWh6HY_fWim42`

View File

@@ -1,9 +1,22 @@
WebKitBrowserSource="WebKit Browser (no CEF)" WebKitBrowserSource="WebKit Browser"
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" URL="URL or local HTML file"
Width="Width" Width="Width"
Height="Height" Height="Height"
FPS="Frames per second" FPS="Frames per second"
PageZoom="Page zoom (%)"
CaptureAudio="Capture browser audio"
Transparent="Transparent background" Transparent="Transparent background"
CustomCSS="Custom CSS" CustomCSS="Custom CSS"
ShutdownWhenHidden="Shut down renderer when source is hidden" ShutdownWhenHidden="Shut down renderer when source is hidden"
Reload="Reload page" Reload="Reload page"
Refresh="Refresh"

View File

@@ -1,9 +1,22 @@
WebKitBrowserSource="Браузер WebKit (без CEF)" WebKitBrowserSource="Браузер WebKit"
CustomBrowserDocks="Пользовательские web-панели..."
CustomBrowserDocksDescription="Добавьте web-панели в меню «Док-панели» OBS. Страницы рендерятся через WebKitGTK."
PanelName="Название панели"
AddPanel="Добавить"
RemovePanel="Удалить"
PanelFieldsRequired="Для каждой панели укажите название и URL."
PanelNamesUnique="Названия панелей не должны повторяться."
PanelLoading="Загрузка web-панели..."
PanelRendererError="Не удалось запустить WebKit-рендерер панели."
PanelRendererMissing="Вспомогательный WebKit-рендерер не установлен."
URL="URL или локальный HTML-файл" URL="URL или локальный HTML-файл"
Width="Ширина" Width="Ширина"
Height="Высота" Height="Высота"
FPS="Кадров в секунду" FPS="Кадров в секунду"
PageZoom="Масштаб страницы (%)"
CaptureAudio="Захватывать звук браузера"
Transparent="Прозрачный фон" Transparent="Прозрачный фон"
CustomCSS="Пользовательский CSS" CustomCSS="Пользовательский CSS"
ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт" ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт"
Reload="Перезагрузить страницу" Reload="Перезагрузить страницу"
Refresh="Обновить"

View File

@@ -15,7 +15,10 @@ fi
$ROOT apk add \ $ROOT apk add \
build-base cmake samurai pkgconf \ build-base cmake samurai pkgconf \
obs-studio obs-studio-dev \ obs-studio obs-studio-dev \
qt6-qtbase-dev \
gtk+3.0 gtk+3.0-dev \ gtk+3.0 gtk+3.0-dev \
webkit2gtk-4.1 webkit2gtk-4.1-dev \ webkit2gtk-4.1 webkit2gtk-4.1-dev \
gst-plugins-good \
pulseaudio-dev \
libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \ libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \
mesa-dri-gallium mesa-dri-gallium

925
src/docks.cpp Normal file
View File

@@ -0,0 +1,925 @@
#include <obs/obs-frontend-api.h>
#include <obs/obs-interaction.h>
#include <obs/obs-module.h>
#include <QAbstractItemView>
#include <QAction>
#include <QApplication>
#include <QByteArray>
#include <QBoxLayout>
#include <QContextMenuEvent>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDockWidget>
#include <QFocusEvent>
#include <QHash>
#include <QHeaderView>
#include <QImage>
#include <QKeyEvent>
#include <QLabel>
#include <QMainWindow>
#include <QMenu>
#include <QMenuBar>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPointer>
#include <QPushButton>
#include <QResizeEvent>
#include <QSet>
#include <QShowEvent>
#include <QSocketNotifier>
#include <QSpinBox>
#include <QTableWidget>
#include <QTimer>
#include <QUuid>
#include <QVBoxLayout>
#include <QVector>
#include <QWheelEvent>
#include <QWidget>
#include <algorithm>
#include <cerrno>
#include <csignal>
#include <cstdint>
#include <cstring>
#include <fcntl.h>
#include <spawn.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <utility>
#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<const uint8_t *>(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<size_t>(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;
int zoomPercent = 100;
};
class WebPanel final : public QWidget {
public:
explicit WebPanel(QString url, int zoomPercent, QWidget *parent = nullptr)
: QWidget(parent), url_(std::move(url)), zoomPercent_(zoomPercent)
{
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 setConfiguration(const QString &url, int zoomPercent)
{
zoomPercent = std::clamp(zoomPercent, 25, 500);
if (url_ == url && zoomPercent_ == zoomPercent)
return;
url_ = url;
zoomPercent_ = zoomPercent;
if (isVisible())
restartRenderer();
}
void setDockTitle(const QString &title)
{
for (QWidget *ancestor = parentWidget(); ancestor; ancestor = ancestor->parentWidget()) {
if (auto *dock = qobject_cast<QDockWidget *>(ancestor)) {
dock->setWindowTitle(title);
break;
}
}
}
protected:
bool event(QEvent *event) override
{
const auto dockClosed = static_cast<QEvent::Type>(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<int32_t, int32_t> rendererPosition(const QPointF &position) const
{
const double sx = width() > 0 ? static_cast<double>(renderWidth_) / width() : 1.0;
const double sy = height() > 0 ? static_cast<double>(renderHeight_) / height() : 1.0;
int x = std::clamp(static_cast<int>(position.x() * sx), 0, std::max(0, renderWidth_ - 1));
int y = std::clamp(static_cast<int>(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<uint32_t>(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<int32_t>(event->nativeVirtualKey());
command.modifiers = interactionModifiers(event->modifiers(), {});
QByteArray utf8 = event->text().toUtf8();
command.text_length = static_cast<uint32_t>(std::min<size_t>(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<uint32_t>(renderWidth_);
header.height = static_cast<uint32_t>(renderHeight_);
header.fps = 30;
header.zoom_percent = static_cast<uint32_t>(zoomPercent_);
header.capture_audio = 0;
header.transparent = 0;
header.hardware_acceleration = 1;
header.url_length = static_cast<uint32_t>(url.size());
return writeAll(controlFd_, &header, sizeof(header)) &&
writeAll(controlFd_, url.constData(), static_cast<size_t>(url.size()));
}
void startRenderer()
{
if (pid_ > 0 || !isVisible())
return;
status_ = text("PanelLoading");
frame_ = QImage();
update();
const double scale = devicePixelRatioF();
renderWidth_ = std::clamp(static_cast<int>(width() * scale), 2, 8192);
renderHeight_ = std::clamp(static_cast<int>(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<off_t>(sharedSize_)) != 0)
return startFailed(text("PanelRendererError"));
shared_ = static_cast<owb_shared_frames *>(
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<uint32_t>(renderWidth_);
shared_->height = static_cast<uint32_t>(renderHeight_);
shared_->stride = static_cast<uint32_t>(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<qsizetype>(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_;
int zoomPercent_ = 100;
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. */
refreshButtonTimer_.stop();
delete refreshButton_;
panels_.clear();
if (menuAction_)
delete menuAction_;
}
void setupFrontend()
{
if (!QApplication::instance())
return;
auto *main = static_cast<QMainWindow *>(obs_frontend_get_main_window());
if (!main)
return;
if (!menuAction_) {
menuAction_ = new QAction(text("CustomBrowserDocks"), main);
QObject::connect(menuAction_, &QAction::triggered, main, [this] { showEditor(); });
if (QMenu *docksMenu = main->findChild<QMenu *>("menuDocks"))
docksMenu->addAction(menuAction_);
else
main->menuBar()->addAction(menuAction_);
}
setupRefreshButton(main);
reconcile();
}
private:
static QString dockId(const QString &id) { return QString::fromUtf8(kDockPrefix) + id; }
static constexpr const char *kSourceId = "webkit_browser_source";
obs_source_t *currentContextSource() const
{
if (!contextSourceLabel_)
return nullptr;
QByteArray name = contextSourceLabel_->text().toUtf8();
return obs_get_source_by_name(name.constData());
}
void updateRefreshButton()
{
if (!refreshButton_)
return;
bool visible = false;
obs_source_t *source = currentContextSource();
if (source) {
const char *id = obs_source_get_unversioned_id(source);
visible = id && strcmp(id, kSourceId) == 0;
obs_source_release(source);
}
refreshButton_->setVisible(visible);
if (sourceInteractButton_)
refreshButton_->setText(sourceInteractButton_->text().isEmpty() ? QString() : text("Refresh"));
}
void setupRefreshButton(QMainWindow *main)
{
if (refreshButton_)
return;
sourceInteractButton_ = main->findChild<QPushButton *>("sourceInteractButton");
contextSourceLabel_ = main->findChild<QLabel *>("contextSourceLabel");
if (!sourceInteractButton_ || !contextSourceLabel_)
return;
auto *layout = qobject_cast<QBoxLayout *>(sourceInteractButton_->parentWidget()->layout());
if (!layout)
return;
refreshButton_ = new QPushButton(text("Refresh"), sourceInteractButton_->parentWidget());
refreshButton_->setObjectName("webkitBrowserRefreshButton");
refreshButton_->setMinimumHeight(22);
refreshButton_->setToolTip(text("Refresh"));
refreshButton_->setIcon(QIcon(":/res/images/refresh.svg"));
refreshButton_->setProperty("class", "icon-refresh");
layout->insertWidget(layout->indexOf(sourceInteractButton_) + 1, refreshButton_);
QObject::connect(refreshButton_, &QPushButton::clicked, main, [this] {
obs_source_t *source = currentContextSource();
if (!source)
return;
const char *id = obs_source_get_unversioned_id(source);
if (id && strcmp(id, kSourceId) == 0) {
obs_properties_t *properties = obs_source_properties(source);
obs_property_t *reload = properties ? obs_properties_get(properties, "reload") : nullptr;
if (reload)
obs_property_button_clicked(reload, source);
if (properties)
obs_properties_destroy(properties);
}
obs_source_release(source);
});
refreshButtonTimer_.setInterval(100);
QObject::connect(&refreshButtonTimer_, &QTimer::timeout, main, [this] { updateRefreshButton(); });
refreshButtonTimer_.start();
updateRefreshButton();
}
static void eventCallback(obs_frontend_event event, void *opaque)
{
auto *manager = static_cast<DockManager *>(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<DockManager *>(opaque)->saveLoad(saveData, saving);
}
static void preloadCallback(obs_data_t *saveData, bool saving, void *opaque)
{
if (!saving)
static_cast<DockManager *>(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_set_int(item, "zoom", definition.zoomPercent);
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")),
static_cast<int>(obs_data_get_int(item, "zoom")),
};
if (definition.zoomPercent == 0)
definition.zoomPercent = 100;
definition.zoomPercent = std::clamp(definition.zoomPercent, 25, 500);
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<QString, DockDefinition> 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]->setConfiguration(definition.url,
definition.zoomPercent);
panels_[definition.id]->setDockTitle(definition.title);
continue;
}
auto *panel = new WebPanel(definition.url, definition.zoomPercent);
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<QWidget *>(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, 3, &dialog);
table->setHorizontalHeaderLabels({text("PanelName"), text("URL"), text("PageZoom")});
table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
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));
auto *zoom = new QSpinBox(table);
zoom->setRange(25, 500);
zoom->setSingleStep(5);
zoom->setSuffix("%");
zoom->setValue(definition.zoomPercent);
table->setCellWidget(row, 2, zoom);
};
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://", 100});
int row = table->rowCount() - 1;
table->setCurrentCell(row, 0);
table->editItem(table->item(row, 0));
});
QObject::connect(remove, &QPushButton::clicked, table, [table] {
QSet<int> rows;
for (QTableWidgetItem *item : table->selectedItems())
rows.insert(item->row());
QList<int> sorted = rows.values();
std::sort(sorted.begin(), sorted.end(), std::greater<int>());
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<DockDefinition> updated;
QSet<QString> 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();
auto *zoom = qobject_cast<QSpinBox *>(table->cellWidget(row, 2));
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, zoom ? zoom->value() : 100});
}
definitions_ = std::move(updated);
reconcile();
obs_frontend_save();
});
dialog.exec();
}
QVector<DockDefinition> definitions_;
QHash<QString, QPointer<WebPanel>> panels_;
QPointer<QAction> menuAction_;
QPointer<QPushButton> sourceInteractButton_;
QPointer<QLabel> contextSourceLabel_;
QPointer<QPushButton> refreshButton_;
QTimer refreshButtonTimer_;
};
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;
}

View File

@@ -1,6 +1,8 @@
#include <obs/obs-module.h> #include <obs/obs-module.h>
#include <obs/obs-interaction.h> #include <obs/obs-interaction.h>
#include <obs/util/platform.h> #include <obs/util/platform.h>
#include <pulse/error.h>
#include <pulse/simple.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
@@ -21,6 +23,7 @@
#include <unistd.h> #include <unistd.h>
#include "protocol.h" #include "protocol.h"
#include "pulse-capture.h"
OBS_DECLARE_MODULE() OBS_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US") OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US")
@@ -29,6 +32,14 @@ MODULE_EXPORT const char *obs_module_description(void)
return "WebKitGTK browser source for OBS (no CEF)"; 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; extern char **environ;
struct browser_source; struct browser_source;
@@ -43,8 +54,11 @@ struct renderer_process {
size_t shared_size; size_t shared_size;
pthread_t frame_thread; pthread_t frame_thread;
pthread_t log_thread; pthread_t log_thread;
pthread_t audio_thread;
bool frame_thread_started; bool frame_thread_started;
bool log_thread_started; bool log_thread_started;
bool audio_thread_started;
struct owb_pulse_sink pulse_sink;
atomic_bool stopping; atomic_bool stopping;
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
@@ -60,6 +74,8 @@ struct browser_source {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t fps; uint32_t fps;
uint32_t zoom_percent;
bool capture_audio;
bool transparent; bool transparent;
bool shutdown_when_hidden; bool shutdown_when_hidden;
bool visible; bool visible;
@@ -187,6 +203,54 @@ static void *log_reader(void *opaque)
return NULL; return NULL;
} }
static void *audio_reader(void *opaque)
{
struct renderer_process *renderer = opaque;
char monitor[OWB_AUDIO_SINK_NAME_SIZE + 16];
snprintf(monitor, sizeof(monitor), "%s.monitor", renderer->pulse_sink.name);
pa_sample_spec specification = {
.format = PA_SAMPLE_FLOAT32NE,
.rate = OWB_AUDIO_SAMPLE_RATE,
.channels = OWB_AUDIO_CHANNELS,
};
pa_buffer_attr buffering = {
.maxlength = (uint32_t)-1,
.tlength = (uint32_t)-1,
.prebuf = (uint32_t)-1,
.minreq = (uint32_t)-1,
.fragsize = OWB_AUDIO_BLOCK_FRAMES * OWB_AUDIO_CHANNELS * sizeof(float),
};
int pulse_error = 0;
pa_simple *stream = pa_simple_new(NULL, "OBS WebKit Browser", PA_STREAM_RECORD,
monitor, "Browser source audio", &specification, NULL,
&buffering, &pulse_error);
if (!stream) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot capture browser audio: %s",
pa_strerror(pulse_error));
return NULL;
}
float samples[OWB_AUDIO_BLOCK_FRAMES * OWB_AUDIO_CHANNELS];
while (!atomic_load(&renderer->stopping)) {
if (pa_simple_read(stream, samples, sizeof(samples), &pulse_error) < 0)
break;
uint64_t duration = (uint64_t)OWB_AUDIO_BLOCK_FRAMES * 1000000000u /
OWB_AUDIO_SAMPLE_RATE;
uint64_t now = os_gettime_ns();
struct obs_source_audio audio = {
.data = {(const uint8_t *)samples},
.frames = OWB_AUDIO_BLOCK_FRAMES,
.speakers = SPEAKERS_STEREO,
.format = AUDIO_FORMAT_FLOAT,
.samples_per_sec = OWB_AUDIO_SAMPLE_RATE,
.timestamp = now > duration ? now - duration : now,
};
obs_source_output_audio(renderer->owner->source, &audio);
}
pa_simple_free(stream);
return NULL;
}
static void terminate_renderer_group(pid_t pid) static void terminate_renderer_group(pid_t pid)
{ {
if (pid <= 0) if (pid <= 0)
@@ -218,6 +282,8 @@ static void stop_renderer_locked(struct browser_source *browser)
renderer->control_fd = -1; renderer->control_fd = -1;
} }
terminate_renderer_group(renderer->pid); terminate_renderer_group(renderer->pid);
if (renderer->pulse_sink.module_index != UINT32_MAX)
owb_pulse_sink_destroy(&renderer->pulse_sink);
if (renderer->frame_fd >= 0) { if (renderer->frame_fd >= 0) {
close(renderer->frame_fd); close(renderer->frame_fd);
renderer->frame_fd = -1; renderer->frame_fd = -1;
@@ -228,6 +294,8 @@ static void stop_renderer_locked(struct browser_source *browser)
pthread_join(renderer->log_thread, NULL); pthread_join(renderer->log_thread, NULL);
else if (renderer->log_fd >= 0) else if (renderer->log_fd >= 0)
close(renderer->log_fd); close(renderer->log_fd);
if (renderer->audio_thread_started)
pthread_join(renderer->audio_thread, NULL);
if (renderer->shared && renderer->shared != MAP_FAILED) if (renderer->shared && renderer->shared != MAP_FAILED)
munmap(renderer->shared, renderer->shared_size); munmap(renderer->shared, renderer->shared_size);
if (renderer->shared_fd >= 0) if (renderer->shared_fd >= 0)
@@ -247,11 +315,15 @@ static bool send_initial_config(struct browser_source *browser, struct renderer_
.width = browser->width, .width = browser->width,
.height = browser->height, .height = browser->height,
.fps = browser->fps, .fps = browser->fps,
.zoom_percent = browser->zoom_percent,
.capture_audio = browser->capture_audio,
.transparent = browser->transparent, .transparent = browser->transparent,
.hardware_acceleration = true, .hardware_acceleration = true,
.url_length = (uint32_t)url_length, .url_length = (uint32_t)url_length,
.css_length = (uint32_t)css_length, .css_length = (uint32_t)css_length,
}; };
if (browser->capture_audio)
snprintf(header.audio_sink, sizeof(header.audio_sink), "%s", renderer->pulse_sink.name);
return write_all_socket(renderer->control_fd, &header, sizeof(header)) && return write_all_socket(renderer->control_fd, &header, sizeof(header)) &&
write_all_socket(renderer->control_fd, browser->url, url_length) && write_all_socket(renderer->control_fd, browser->url, url_length) &&
write_all_socket(renderer->control_fd, browser->css, css_length); write_all_socket(renderer->control_fd, browser->css, css_length);
@@ -277,6 +349,16 @@ static bool start_renderer_locked(struct browser_source *browser)
int frames[2] = {-1, -1}; int frames[2] = {-1, -1};
int logs[2] = {-1, -1}; int logs[2] = {-1, -1};
int shared_fd = -1; int shared_fd = -1;
struct owb_pulse_sink pulse_sink = {.module_index = UINT32_MAX};
if (browser->capture_audio) {
char pulse_error[256];
if (!owb_pulse_sink_create(&pulse_sink, pulse_error, sizeof(pulse_error))) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot create browser audio sink: %s",
pulse_error);
bfree(helper);
return false;
}
}
struct owb_shared_frames *shared = MAP_FAILED; struct owb_shared_frames *shared = MAP_FAILED;
size_t shared_size = owb_shared_size(browser->width, browser->height); size_t shared_size = owb_shared_size(browser->width, browser->height);
shared_fd = memfd_create("obs-webkit-browser-frames", MFD_CLOEXEC); shared_fd = memfd_create("obs-webkit-browser-frames", MFD_CLOEXEC);
@@ -295,7 +377,7 @@ static bool start_renderer_locked(struct browser_source *browser)
shared->width = browser->width; shared->width = browser->width;
shared->height = browser->height; shared->height = browser->height;
shared->stride = browser->width * 4; 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) { 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)); blog(LOG_ERROR, "[obs-webkit-browser] IPC setup failed: %s", strerror(errno));
goto fail; goto fail;
@@ -351,6 +433,8 @@ static bool start_renderer_locked(struct browser_source *browser)
renderer->shared_fd = shared_fd; renderer->shared_fd = shared_fd;
renderer->shared = shared; renderer->shared = shared;
renderer->shared_size = shared_size; renderer->shared_size = shared_size;
renderer->pulse_sink = pulse_sink;
pulse_sink.module_index = UINT32_MAX;
renderer->width = browser->width; renderer->width = browser->width;
renderer->height = browser->height; renderer->height = browser->height;
renderer->owner = browser; renderer->owner = browser;
@@ -367,6 +451,7 @@ static bool start_renderer_locked(struct browser_source *browser)
close(renderer->log_fd); close(renderer->log_fd);
munmap(renderer->shared, renderer->shared_size); munmap(renderer->shared, renderer->shared_size);
close(renderer->shared_fd); close(renderer->shared_fd);
owb_pulse_sink_destroy(&renderer->pulse_sink);
free(renderer); free(renderer);
goto fail; goto fail;
} }
@@ -374,12 +459,21 @@ static bool start_renderer_locked(struct browser_source *browser)
renderer->frame_thread_started = true; renderer->frame_thread_started = true;
if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0) if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0)
renderer->log_thread_started = true; renderer->log_thread_started = true;
if (browser->capture_audio &&
pthread_create(&renderer->audio_thread, NULL, audio_reader, renderer) == 0)
renderer->audio_thread_started = true;
if (!renderer->frame_thread_started) { if (!renderer->frame_thread_started) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread"); blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread");
browser->renderer = renderer; browser->renderer = renderer;
stop_renderer_locked(browser); stop_renderer_locked(browser);
goto fail; goto fail;
} }
if (browser->capture_audio && !renderer->audio_thread_started) {
blog(LOG_ERROR, "[obs-webkit-browser] Cannot start browser audio reader thread");
browser->renderer = renderer;
stop_renderer_locked(browser);
goto fail;
}
browser->renderer = renderer; browser->renderer = renderer;
blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width, blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width,
browser->height, browser->fps); browser->height, browser->fps);
@@ -400,6 +494,7 @@ fail:
munmap(shared, shared_size); munmap(shared, shared_size);
if (shared_fd >= 0) if (shared_fd >= 0)
close(shared_fd); close(shared_fd);
owb_pulse_sink_destroy(&pulse_sink);
return false; return false;
} }
@@ -426,11 +521,14 @@ static void browser_update(void *data, obs_data_t *settings)
browser->width = setting_u32(settings, "width", 2, 8192); browser->width = setting_u32(settings, "width", 2, 8192);
browser->height = setting_u32(settings, "height", 2, 8192); browser->height = setting_u32(settings, "height", 2, 8192);
browser->fps = setting_u32(settings, "fps", 1, 60); browser->fps = setting_u32(settings, "fps", 1, 60);
browser->zoom_percent = setting_u32(settings, "zoom", 25, 500);
browser->capture_audio = obs_data_get_bool(settings, "capture_audio");
browser->transparent = obs_data_get_bool(settings, "transparent"); browser->transparent = obs_data_get_bool(settings, "transparent");
browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden"); browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden");
stop_renderer_locked(browser); stop_renderer_locked(browser);
start_renderer_locked(browser); start_renderer_locked(browser);
pthread_mutex_unlock(&browser->mutex); pthread_mutex_unlock(&browser->mutex);
obs_source_set_audio_active(browser->source, browser->capture_audio);
} }
static void *browser_create(obs_data_t *settings, obs_source_t *source) static void *browser_create(obs_data_t *settings, obs_source_t *source)
@@ -471,6 +569,8 @@ static void browser_defaults(obs_data_t *settings)
obs_data_set_default_int(settings, "width", 1280); obs_data_set_default_int(settings, "width", 1280);
obs_data_set_default_int(settings, "height", 720); obs_data_set_default_int(settings, "height", 720);
obs_data_set_default_int(settings, "fps", 30); obs_data_set_default_int(settings, "fps", 30);
obs_data_set_default_int(settings, "zoom", 100);
obs_data_set_default_bool(settings, "capture_audio", false);
obs_data_set_default_bool(settings, "transparent", true); obs_data_set_default_bool(settings, "transparent", true);
obs_data_set_default_bool(settings, "shutdown_when_hidden", false); obs_data_set_default_bool(settings, "shutdown_when_hidden", false);
} }
@@ -490,6 +590,8 @@ static obs_properties_t *browser_properties(void *data)
obs_properties_add_int(props, "width", obs_module_text("Width"), 2, 8192, 1); obs_properties_add_int(props, "width", obs_module_text("Width"), 2, 8192, 1);
obs_properties_add_int(props, "height", obs_module_text("Height"), 2, 8192, 1); obs_properties_add_int(props, "height", obs_module_text("Height"), 2, 8192, 1);
obs_properties_add_int(props, "fps", obs_module_text("FPS"), 1, 60, 1); obs_properties_add_int(props, "fps", obs_module_text("FPS"), 1, 60, 1);
obs_properties_add_int_slider(props, "zoom", obs_module_text("PageZoom"), 25, 500, 5);
obs_properties_add_bool(props, "capture_audio", obs_module_text("CaptureAudio"));
obs_properties_add_bool(props, "transparent", obs_module_text("Transparent")); obs_properties_add_bool(props, "transparent", obs_module_text("Transparent"));
obs_properties_add_text(props, "css", obs_module_text("CustomCSS"), OBS_TEXT_MULTILINE); obs_properties_add_text(props, "css", obs_module_text("CustomCSS"), OBS_TEXT_MULTILINE);
obs_properties_add_bool(props, "shutdown_when_hidden", obs_module_text("ShutdownWhenHidden")); obs_properties_add_bool(props, "shutdown_when_hidden", obs_module_text("ShutdownWhenHidden"));
@@ -581,7 +683,9 @@ static const char *browser_name(void *unused)
static struct obs_source_info browser_info = { static struct obs_source_info browser_info = {
.id = "webkit_browser_source", .id = "webkit_browser_source",
.type = OBS_SOURCE_TYPE_INPUT, .type = OBS_SOURCE_TYPE_INPUT,
.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_INTERACTION, .icon_type = OBS_ICON_TYPE_BROWSER,
.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_INTERACTION |
OBS_SOURCE_SRGB,
.get_name = browser_name, .get_name = browser_name,
.create = browser_create, .create = browser_create,
.destroy = browser_destroy, .destroy = browser_destroy,
@@ -602,6 +706,12 @@ static struct obs_source_info browser_info = {
bool obs_module_load(void) bool obs_module_load(void)
{ {
obs_register_source(&browser_info); obs_register_source(&browser_info);
owb_docks_initialize();
blog(LOG_INFO, "[obs-webkit-browser] Loaded WebKitGTK browser source"); blog(LOG_INFO, "[obs-webkit-browser] Loaded WebKitGTK browser source");
return true; return true;
} }
void obs_module_unload(void)
{
owb_docks_shutdown();
}

View File

@@ -5,8 +5,9 @@
#define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */ #define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */
#define OWB_COMMAND_MAGIC 0x4f574245u /* OWBE */ #define OWB_COMMAND_MAGIC 0x4f574245u /* OWBE */
#define OWB_PROTOCOL_VERSION 2u #define OWB_PROTOCOL_VERSION 4u
#define OWB_MAX_STRING (16u * 1024u * 1024u) #define OWB_MAX_STRING (16u * 1024u * 1024u)
#define OWB_AUDIO_SINK_NAME_SIZE 64u
#define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */ #define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */
#define OWB_SHARED_FRAME_FD 198 #define OWB_SHARED_FRAME_FD 198
#define OWB_SHARED_FRAME_SLOTS 2u #define OWB_SHARED_FRAME_SLOTS 2u
@@ -17,10 +18,13 @@ struct owb_config_header {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t fps; uint32_t fps;
uint32_t zoom_percent;
uint32_t capture_audio;
uint32_t transparent; uint32_t transparent;
uint32_t hardware_acceleration; uint32_t hardware_acceleration;
uint32_t url_length; uint32_t url_length;
uint32_t css_length; uint32_t css_length;
char audio_sink[OWB_AUDIO_SINK_NAME_SIZE];
}; };
struct owb_shared_frames { struct owb_shared_frames {
@@ -29,9 +33,9 @@ struct owb_shared_frames {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t stride; uint32_t stride;
uint32_t slots; uint32_t slot_count;
uint64_t sequence[OWB_SHARED_FRAME_SLOTS]; 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) static inline size_t owb_frame_size(uint32_t width, uint32_t height)
@@ -41,7 +45,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) 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; owb_frame_size(width, height) * OWB_SHARED_FRAME_SLOTS;
} }

174
src/pulse-capture.c Normal file
View File

@@ -0,0 +1,174 @@
#include "pulse-capture.h"
#include <pulse/error.h>
#include <pulse/introspect.h>
#include <pulse/mainloop.h>
#include <pulse/pulseaudio.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
struct pulse_connection {
pa_mainloop *mainloop;
pa_context *context;
};
struct index_result {
bool complete;
uint32_t index;
};
struct success_result {
bool complete;
bool success;
};
static atomic_uint sink_serial = 1;
static uint64_t monotonic_milliseconds(void)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u;
}
static bool pulse_iterate_until(struct pulse_connection *connection, bool *complete,
uint64_t deadline)
{
while (!*complete && monotonic_milliseconds() < deadline) {
int result = 0;
if (pa_mainloop_iterate(connection->mainloop, 0, &result) < 0)
return false;
pa_context_state_t state = pa_context_get_state(connection->context);
if (!PA_CONTEXT_IS_GOOD(state))
return false;
struct timespec pause = {.tv_nsec = 10000000L};
nanosleep(&pause, NULL);
}
return *complete;
}
static bool pulse_connection_open(struct pulse_connection *connection, char *error,
size_t error_size)
{
memset(connection, 0, sizeof(*connection));
connection->mainloop = pa_mainloop_new();
if (!connection->mainloop)
goto fail;
connection->context = pa_context_new(pa_mainloop_get_api(connection->mainloop),
"OBS WebKit Browser");
if (!connection->context || pa_context_connect(connection->context, NULL,
PA_CONTEXT_NOFLAGS, NULL) < 0)
goto fail;
uint64_t deadline = monotonic_milliseconds() + 3000;
while (monotonic_milliseconds() < deadline) {
pa_context_state_t state = pa_context_get_state(connection->context);
if (state == PA_CONTEXT_READY)
return true;
if (!PA_CONTEXT_IS_GOOD(state))
break;
int result = 0;
if (pa_mainloop_iterate(connection->mainloop, 0, &result) < 0)
break;
struct timespec pause = {.tv_nsec = 10000000L};
nanosleep(&pause, NULL);
}
fail:
if (error && error_size) {
int code = connection->context ? pa_context_errno(connection->context) : PA_ERR_INTERNAL;
snprintf(error, error_size, "%s", pa_strerror(code));
}
if (connection->context) {
pa_context_disconnect(connection->context);
pa_context_unref(connection->context);
}
if (connection->mainloop)
pa_mainloop_free(connection->mainloop);
memset(connection, 0, sizeof(*connection));
return false;
}
static void pulse_connection_close(struct pulse_connection *connection)
{
if (connection->context) {
pa_context_disconnect(connection->context);
pa_context_unref(connection->context);
}
if (connection->mainloop)
pa_mainloop_free(connection->mainloop);
}
static void module_loaded(pa_context *context, uint32_t index, void *opaque)
{
(void)context;
struct index_result *result = opaque;
result->index = index;
result->complete = true;
}
static void module_unloaded(pa_context *context, int success, void *opaque)
{
(void)context;
struct success_result *result = opaque;
result->success = success != 0;
result->complete = true;
}
bool owb_pulse_sink_create(struct owb_pulse_sink *sink, char *error, size_t error_size)
{
memset(sink, 0, sizeof(*sink));
sink->module_index = PA_INVALID_INDEX;
unsigned serial = atomic_fetch_add(&sink_serial, 1);
snprintf(sink->name, sizeof(sink->name), "owb_%ld_%u", (long)getpid(), serial);
struct pulse_connection connection;
if (!pulse_connection_open(&connection, error, error_size))
return false;
char arguments[256];
snprintf(arguments, sizeof(arguments),
"sink_name=%s rate=%u channels=%u sink_properties=device.description=OBS_WebKit_Browser",
sink->name, OWB_AUDIO_SAMPLE_RATE, OWB_AUDIO_CHANNELS);
struct index_result result = {.index = PA_INVALID_INDEX};
pa_operation *operation = pa_context_load_module(connection.context, "module-null-sink",
arguments, module_loaded, &result);
bool okay = operation && pulse_iterate_until(&connection, &result.complete,
monotonic_milliseconds() + 3000) &&
result.index != PA_INVALID_INDEX;
if (operation)
pa_operation_unref(operation);
if (!okay && error && error_size)
snprintf(error, error_size, "%s", pa_strerror(pa_context_errno(connection.context)));
pulse_connection_close(&connection);
if (!okay) {
sink->name[0] = '\0';
return false;
}
sink->module_index = result.index;
return true;
}
void owb_pulse_sink_destroy(struct owb_pulse_sink *sink)
{
if (!sink || sink->module_index == PA_INVALID_INDEX)
return;
struct pulse_connection connection;
if (pulse_connection_open(&connection, NULL, 0)) {
struct success_result result = {0};
pa_operation *operation = pa_context_unload_module(connection.context,
sink->module_index,
module_unloaded, &result);
if (operation) {
pulse_iterate_until(&connection, &result.complete,
monotonic_milliseconds() + 3000);
pa_operation_unref(operation);
}
pulse_connection_close(&connection);
}
sink->module_index = PA_INVALID_INDEX;
sink->name[0] = '\0';
}

19
src/pulse-capture.h Normal file
View File

@@ -0,0 +1,19 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "protocol.h"
#define OWB_AUDIO_SAMPLE_RATE 48000u
#define OWB_AUDIO_CHANNELS 2u
#define OWB_AUDIO_BLOCK_FRAMES 480u
struct owb_pulse_sink {
uint32_t module_index;
char name[OWB_AUDIO_SINK_NAME_SIZE];
};
bool owb_pulse_sink_create(struct owb_pulse_sink *sink, char *error, size_t error_size);
void owb_pulse_sink_destroy(struct owb_pulse_sink *sink);

View File

@@ -14,15 +14,19 @@
#include <limits.h> #include <limits.h>
#include <pthread.h> #include <pthread.h>
#include <signal.h> #include <signal.h>
#include <stdarg.h>
#include <stdatomic.h> #include <stdatomic.h>
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/mman.h> #include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/un.h>
#include <sys/ipc.h> #include <sys/ipc.h>
#include <sys/shm.h> #include <sys/shm.h>
#include <sys/wait.h>
#include <unistd.h> #include <unistd.h>
#include "protocol.h" #include "protocol.h"
@@ -31,7 +35,10 @@ struct config {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t fps; uint32_t fps;
uint32_t zoom_percent;
bool capture_audio;
bool transparent; bool transparent;
char audio_sink[OWB_AUDIO_SINK_NAME_SIZE];
char *url; char *url;
char *css; char *css;
}; };
@@ -40,6 +47,13 @@ struct renderer {
struct config config; struct config config;
GtkWidget *window; GtkWidget *window;
WebKitWebView *web_view; WebKitWebView *web_view;
int control_fd;
int frame_fd;
int log_fd;
int shared_fd;
int client_fd;
guint control_source;
guint client_source;
struct owb_shared_frames *shared; struct owb_shared_frames *shared;
size_t shared_size; size_t shared_size;
uint64_t next_sequence; uint64_t next_sequence;
@@ -59,6 +73,22 @@ struct renderer {
bool capture_attached; bool capture_attached;
}; };
static GList *renderers;
static WebKitWebContext *shared_context;
static guint idle_source;
static void destroy_renderer(struct renderer *renderer);
static void renderer_log(struct renderer *renderer, const char *format, ...)
{
if (!renderer || renderer->log_fd < 0)
return;
va_list arguments;
va_start(arguments, format);
vdprintf(renderer->log_fd, format, arguments);
va_end(arguments);
}
static bool read_exact(int fd, void *data, size_t size) static bool read_exact(int fd, void *data, size_t size)
{ {
uint8_t *cursor = data; uint8_t *cursor = data;
@@ -74,27 +104,35 @@ static bool read_exact(int fd, void *data, size_t size)
return true; return true;
} }
static bool read_config(struct config *config) static bool read_config(int fd, struct config *config)
{ {
struct owb_config_header header; struct owb_config_header header;
if (!read_exact(STDIN_FILENO, &header, sizeof(header)) || header.magic != OWB_CONFIG_MAGIC || if (!read_exact(fd, &header, sizeof(header)) || header.magic != OWB_CONFIG_MAGIC ||
header.version != OWB_PROTOCOL_VERSION || header.width < 2 || header.height < 2 || header.version != OWB_PROTOCOL_VERSION || header.width < 2 || header.height < 2 ||
header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 || header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 ||
header.zoom_percent < 25 || header.zoom_percent > 500 ||
header.capture_audio > 1 ||
(header.capture_audio &&
(!header.audio_sink[0] ||
!memchr(header.audio_sink, '\0', sizeof(header.audio_sink)))) ||
header.url_length > OWB_MAX_STRING || header.css_length > OWB_MAX_STRING) header.url_length > OWB_MAX_STRING || header.css_length > OWB_MAX_STRING)
return false; return false;
config->width = header.width; config->width = header.width;
config->height = header.height; config->height = header.height;
config->fps = header.fps; config->fps = header.fps;
config->zoom_percent = header.zoom_percent;
config->capture_audio = header.capture_audio != 0;
config->transparent = header.transparent != 0; config->transparent = header.transparent != 0;
/* Protocol v2 retains this legacy flag, but this renderer always enables the memcpy(config->audio_sink, header.audio_sink, sizeof(config->audio_sink));
/* The protocol retains this legacy flag, but this renderer always enables the
* WebKit compositor. */ * WebKit compositor. */
(void)header.hardware_acceleration; (void)header.hardware_acceleration;
config->url = calloc((size_t)header.url_length + 1, 1); config->url = calloc((size_t)header.url_length + 1, 1);
config->css = calloc((size_t)header.css_length + 1, 1); config->css = calloc((size_t)header.css_length + 1, 1);
if (!config->url || !config->css) if (!config->url || !config->css)
return false; return false;
return read_exact(STDIN_FILENO, config->url, header.url_length) && return read_exact(fd, config->url, header.url_length) &&
read_exact(STDIN_FILENO, config->css, header.css_length); read_exact(fd, config->css, header.css_length);
} }
static char *normalize_uri(const char *value) static char *normalize_uri(const char *value)
@@ -162,10 +200,10 @@ static bool map_shared_frames(struct renderer *renderer)
{ {
struct stat info; struct stat info;
renderer->shared_size = owb_shared_size(renderer->config.width, renderer->config.height); renderer->shared_size = owb_shared_size(renderer->config.width, renderer->config.height);
if (fstat(OWB_SHARED_FRAME_FD, &info) != 0 || (size_t)info.st_size < renderer->shared_size) if (fstat(renderer->shared_fd, &info) != 0 || (size_t)info.st_size < renderer->shared_size)
return false; return false;
renderer->shared = mmap(NULL, renderer->shared_size, PROT_READ | PROT_WRITE, MAP_SHARED, renderer->shared = mmap(NULL, renderer->shared_size, PROT_READ | PROT_WRITE, MAP_SHARED,
OWB_SHARED_FRAME_FD, 0); renderer->shared_fd, 0);
if (renderer->shared == MAP_FAILED) if (renderer->shared == MAP_FAILED)
return false; return false;
return renderer->shared->magic == OWB_SHARED_MAGIC && return renderer->shared->magic == OWB_SHARED_MAGIC &&
@@ -173,7 +211,7 @@ static bool map_shared_frames(struct renderer *renderer)
renderer->shared->width == renderer->config.width && renderer->shared->width == renderer->config.width &&
renderer->shared->height == renderer->config.height && renderer->shared->height == renderer->config.height &&
renderer->shared->stride == renderer->config.width * 4 && 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) static bool initialize_x11_capture(struct renderer *renderer)
@@ -299,7 +337,7 @@ static void publish_frame(struct renderer *renderer, const uint8_t *pixels, int
} }
__atomic_store_n(&renderer->shared->sequence[slot], complete_sequence, __ATOMIC_RELEASE); __atomic_store_n(&renderer->shared->sequence[slot], complete_sequence, __ATOMIC_RELEASE);
uint8_t notification = (uint8_t)slot; uint8_t notification = (uint8_t)slot;
ssize_t ignored = write(STDOUT_FILENO, &notification, sizeof(notification)); ssize_t ignored = write(renderer->frame_fd, &notification, sizeof(notification));
(void)ignored; (void)ignored;
} }
@@ -352,7 +390,7 @@ static void *capture_loop(void *opaque)
None, None); None, None);
bool published = publish_x11_frame(renderer); bool published = publish_x11_frame(renderer);
if (published && !transport_reported && process_uses_drm_render_node()) { if (published && !transport_reported && process_uses_drm_render_node()) {
fprintf(stderr, "DRM render node: active\n"); renderer_log(renderer, "DRM render node: active\n");
transport_reported = true; transport_reported = true;
} }
first_frame = false; first_frame = false;
@@ -386,6 +424,16 @@ static GdkModifierType modifiers_from_obs(uint32_t modifiers)
return state; 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) static void dispatch_command(struct renderer *renderer, const struct owb_command *command)
{ {
if (command->magic != OWB_COMMAND_MAGIC) if (command->magic != OWB_COMMAND_MAGIC)
@@ -405,6 +453,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command
if (command->type == OWB_MOUSE_MOVE) { if (command->type == OWB_MOUSE_MOVE) {
if (command->value1) { if (command->value1) {
GdkEvent *event = gdk_event_new(GDK_LEAVE_NOTIFY); GdkEvent *event = gdk_event_new(GDK_LEAVE_NOTIFY);
set_event_device(event, window, false);
event->crossing.window = g_object_ref(window); event->crossing.window = g_object_ref(window);
event->crossing.send_event = TRUE; event->crossing.send_event = TRUE;
event->crossing.time = GDK_CURRENT_TIME; event->crossing.time = GDK_CURRENT_TIME;
@@ -418,6 +467,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command
return; return;
} }
GdkEvent *event = gdk_event_new(GDK_MOTION_NOTIFY); GdkEvent *event = gdk_event_new(GDK_MOTION_NOTIFY);
set_event_device(event, window, false);
event->motion.window = g_object_ref(window); event->motion.window = g_object_ref(window);
event->motion.send_event = TRUE; event->motion.send_event = TRUE;
event->motion.time = GDK_CURRENT_TIME; event->motion.time = GDK_CURRENT_TIME;
@@ -434,6 +484,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command
: command->text_length >= 3 ? GDK_3BUTTON_PRESS : command->text_length >= 3 ? GDK_3BUTTON_PRESS
: GDK_BUTTON_PRESS; : GDK_BUTTON_PRESS;
GdkEvent *event = gdk_event_new(type); GdkEvent *event = gdk_event_new(type);
set_event_device(event, window, false);
event->button.window = g_object_ref(window); event->button.window = g_object_ref(window);
event->button.send_event = TRUE; event->button.send_event = TRUE;
event->button.time = GDK_CURRENT_TIME; event->button.time = GDK_CURRENT_TIME;
@@ -447,6 +498,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command
} }
if (command->type == OWB_MOUSE_WHEEL) { if (command->type == OWB_MOUSE_WHEEL) {
GdkEvent *event = gdk_event_new(GDK_SCROLL); GdkEvent *event = gdk_event_new(GDK_SCROLL);
set_event_device(event, window, false);
event->scroll.window = g_object_ref(window); event->scroll.window = g_object_ref(window);
event->scroll.send_event = TRUE; event->scroll.send_event = TRUE;
event->scroll.time = GDK_CURRENT_TIME; event->scroll.time = GDK_CURRENT_TIME;
@@ -462,6 +514,7 @@ static void dispatch_command(struct renderer *renderer, const struct owb_command
} }
if (command->type == OWB_KEY) { if (command->type == OWB_KEY) {
GdkEvent *event = gdk_event_new(command->value1 ? GDK_KEY_RELEASE : GDK_KEY_PRESS); 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.window = g_object_ref(window);
event->key.send_event = TRUE; event->key.send_event = TRUE;
event->key.time = GDK_CURRENT_TIME; event->key.time = GDK_CURRENT_TIME;
@@ -483,7 +536,8 @@ static gboolean control_ready(gint fd, GIOCondition condition, gpointer opaque)
{ {
struct renderer *renderer = opaque; struct renderer *renderer = opaque;
if (condition & (G_IO_HUP | G_IO_ERR)) { if (condition & (G_IO_HUP | G_IO_ERR)) {
gtk_main_quit(); renderer->control_source = 0;
destroy_renderer(renderer);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
for (;;) { for (;;) {
@@ -494,7 +548,8 @@ static gboolean control_ready(gint fd, GIOCondition condition, gpointer opaque)
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
return G_SOURCE_CONTINUE; return G_SOURCE_CONTINUE;
if (count <= 0) { if (count <= 0) {
gtk_main_quit(); renderer->control_source = 0;
destroy_renderer(renderer);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
renderer->command_used += (size_t)count; renderer->command_used += (size_t)count;
@@ -515,22 +570,14 @@ static gboolean clear_window(GtkWidget *widget, cairo_t *context, gpointer unuse
return FALSE; return FALSE;
} }
static gboolean terminate_renderer(gpointer opaque)
{
struct renderer *renderer = opaque;
atomic_store_explicit(&renderer->stopping, true, memory_order_release);
gtk_main_quit();
return G_SOURCE_REMOVE;
}
static void script_message_received(WebKitUserContentManager *manager, static void script_message_received(WebKitUserContentManager *manager,
WebKitJavascriptResult *message, gpointer unused) WebKitJavascriptResult *message, gpointer opaque)
{ {
(void)manager; (void)manager;
(void)unused; struct renderer *renderer = opaque;
JSCValue *value = webkit_javascript_result_get_js_value(message); JSCValue *value = webkit_javascript_result_get_js_value(message);
char *text = jsc_value_to_string(value); char *text = jsc_value_to_string(value);
fprintf(stderr, "Page: %s\n", text ? text : "(empty message)"); renderer_log(renderer, "Page: %s\n", text ? text : "(empty message)");
g_free(text); g_free(text);
} }
@@ -539,34 +586,102 @@ static gboolean load_failed(WebKitWebView *view, WebKitLoadEvent event, const ch
{ {
(void)view; (void)view;
(void)event; (void)event;
(void)unused; struct renderer *renderer = unused;
fprintf(stderr, "Page load failed for %s: %s\n", uri, error->message); renderer_log(renderer, "Page load failed for %s: %s\n", uri, error->message);
return FALSE; return FALSE;
} }
int main(void) static gboolean exit_when_idle(gpointer unused)
{ {
struct renderer renderer = {0}; (void)unused;
atomic_init(&renderer.stopping, false); idle_source = 0;
if (!read_config(&renderer.config)) { if (!renderers)
fprintf(stderr, "Invalid renderer configuration\n"); gtk_main_quit();
return 2; return G_SOURCE_REMOVE;
}
static void destroy_renderer(struct renderer *renderer)
{
if (!renderer)
return;
renderers = g_list_remove(renderers, renderer);
if (renderer->control_source)
g_source_remove(renderer->control_source);
if (renderer->client_source)
g_source_remove(renderer->client_source);
atomic_store_explicit(&renderer->stopping, true, memory_order_release);
if (renderer->capture_thread_started)
pthread_join(renderer->capture_thread, NULL);
destroy_x11_capture(renderer);
if (renderer->window)
gtk_widget_destroy(renderer->window);
if (renderer->shared && renderer->shared != MAP_FAILED)
munmap(renderer->shared, renderer->shared_size);
if (renderer->control_fd >= 0)
close(renderer->control_fd);
if (renderer->frame_fd >= 0)
close(renderer->frame_fd);
if (renderer->log_fd >= 0)
close(renderer->log_fd);
if (renderer->shared_fd >= 0)
close(renderer->shared_fd);
if (renderer->client_fd >= 0)
close(renderer->client_fd);
free(renderer->config.url);
free(renderer->config.css);
free(renderer);
if (!renderers && !idle_source)
idle_source = g_timeout_add_seconds(2, exit_when_idle, NULL);
}
static gboolean client_ready(gint fd, GIOCondition condition, gpointer opaque)
{
struct renderer *renderer = opaque;
if (condition & (G_IO_HUP | G_IO_ERR | G_IO_NVAL)) {
renderer->client_source = 0;
destroy_renderer(renderer);
return G_SOURCE_REMOVE;
} }
prepare_webkit(); char ignored[16];
if (!map_shared_frames(&renderer)) { ssize_t count = recv(fd, ignored, sizeof(ignored), MSG_DONTWAIT);
fprintf(stderr, "Cannot map shared frame transport\n"); if (count == 0) {
return 4; renderer->client_source = 0;
destroy_renderer(renderer);
return G_SOURCE_REMOVE;
} }
if (!gtk_init_check(NULL, NULL)) { return G_SOURCE_CONTINUE;
fprintf(stderr, "GTK could not connect to the OBS display\n"); }
return 5;
static struct renderer *create_renderer(int client_fd, const int descriptors[4])
{
struct renderer *renderer = calloc(1, sizeof(*renderer));
if (!renderer) {
for (size_t index = 0; index < 4; index++)
close(descriptors[index]);
close(client_fd);
return NULL;
}
renderer->control_fd = descriptors[0];
renderer->frame_fd = descriptors[1];
renderer->log_fd = descriptors[2];
renderer->shared_fd = descriptors[3];
renderer->client_fd = client_fd;
renderer->capture_shm.shmid = -1;
atomic_init(&renderer->stopping, false);
if (!read_config(renderer->control_fd, &renderer->config)) {
renderer_log(renderer, "Invalid renderer configuration\n");
goto fail;
}
if (!map_shared_frames(renderer)) {
renderer_log(renderer, "Cannot map shared frame transport\n");
goto fail;
} }
WebKitUserContentManager *manager = webkit_user_content_manager_new(); WebKitUserContentManager *manager = webkit_user_content_manager_new();
g_signal_connect(manager, "script-message-received::owbLog", g_signal_connect(manager, "script-message-received::owbLog",
G_CALLBACK(script_message_received), NULL); G_CALLBACK(script_message_received), renderer);
if (!webkit_user_content_manager_register_script_message_handler(manager, "owbLog")) if (!webkit_user_content_manager_register_script_message_handler(manager, "owbLog"))
fprintf(stderr, "Cannot register page diagnostic message handler\n"); renderer_log(renderer, "Cannot register page diagnostic message handler\n");
static const char error_reporter[] = static const char error_reporter[] =
"(()=>{const p=(k,v)=>{try{window.webkit.messageHandlers.owbLog.postMessage(k+': '+v)}catch{}};" "(()=>{const p=(k,v)=>{try{window.webkit.messageHandlers.owbLog.postMessage(k+': '+v)}catch{}};"
"addEventListener('error',e=>p('error',e.message+' @ '+e.filename+':'+e.lineno));" "addEventListener('error',e=>p('error',e.message+' @ '+e.filename+':'+e.lineno));"
@@ -577,7 +692,7 @@ int main(void)
NULL, NULL); NULL, NULL);
webkit_user_content_manager_add_script(manager, reporter); webkit_user_content_manager_add_script(manager, reporter);
webkit_user_script_unref(reporter); webkit_user_script_unref(reporter);
if (renderer.config.transparent) { if (renderer->config.transparent) {
static const char transparent_page_css[] = static const char transparent_page_css[] =
"html,body{background:transparent!important}"; "html,body{background:transparent!important}";
WebKitUserStyleSheet *transparent_sheet = webkit_user_style_sheet_new( WebKitUserStyleSheet *transparent_sheet = webkit_user_style_sheet_new(
@@ -586,96 +701,330 @@ int main(void)
webkit_user_content_manager_add_style_sheet(manager, transparent_sheet); webkit_user_content_manager_add_style_sheet(manager, transparent_sheet);
webkit_user_style_sheet_unref(transparent_sheet); webkit_user_style_sheet_unref(transparent_sheet);
} }
if (*renderer.config.css) { if (*renderer->config.css) {
WebKitUserStyleSheet *sheet = webkit_user_style_sheet_new(renderer.config.css, WebKitUserStyleSheet *sheet = webkit_user_style_sheet_new(renderer->config.css,
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, WEBKIT_USER_STYLE_LEVEL_USER, NULL, NULL); WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, WEBKIT_USER_STYLE_LEVEL_USER, NULL, NULL);
webkit_user_content_manager_add_style_sheet(manager, sheet); webkit_user_content_manager_add_style_sheet(manager, sheet);
webkit_user_style_sheet_unref(sheet); webkit_user_style_sheet_unref(sheet);
} }
renderer.web_view = WEBKIT_WEB_VIEW(webkit_web_view_new_with_user_content_manager(manager)); WebKitWebsitePolicies *policies = webkit_website_policies_new_with_policies(
g_signal_connect(renderer.web_view, "load-failed", G_CALLBACK(load_failed), NULL); "autoplay", WEBKIT_AUTOPLAY_ALLOW, NULL);
renderer->web_view = WEBKIT_WEB_VIEW(g_object_new(
WEBKIT_TYPE_WEB_VIEW, "web-context", shared_context,
"user-content-manager", manager, "website-policies", policies, NULL));
g_object_unref(policies);
g_signal_connect(renderer->web_view, "load-failed", G_CALLBACK(load_failed), renderer);
g_object_unref(manager); g_object_unref(manager);
WebKitSettings *settings = webkit_web_view_get_settings(renderer.web_view); WebKitSettings *settings = webkit_web_view_get_settings(renderer->web_view);
webkit_settings_set_enable_html5_local_storage(settings, TRUE);
webkit_settings_set_enable_webgl(settings, TRUE); webkit_settings_set_enable_webgl(settings, TRUE);
webkit_settings_set_enable_page_cache(settings, TRUE); webkit_settings_set_enable_page_cache(settings, TRUE);
webkit_settings_set_enable_smooth_scrolling(settings, TRUE); webkit_settings_set_enable_smooth_scrolling(settings, TRUE);
webkit_settings_set_media_playback_requires_user_gesture(settings, FALSE); webkit_settings_set_media_playback_requires_user_gesture(settings, FALSE);
webkit_settings_set_hardware_acceleration_policy(settings, webkit_settings_set_hardware_acceleration_policy(settings,
WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS); WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS);
webkit_web_view_set_is_muted(renderer.web_view, TRUE); webkit_web_view_set_zoom_level(renderer->web_view,
GdkRGBA background = renderer.config.transparent ? (GdkRGBA){0, 0, 0, 0} : (GdkRGBA){1, 1, 1, 1}; (double)renderer->config.zoom_percent / 100.0);
webkit_web_view_set_background_color(renderer.web_view, &background); webkit_web_view_set_is_muted(renderer->web_view, !renderer->config.capture_audio);
GdkRGBA background = renderer->config.transparent ? (GdkRGBA){0, 0, 0, 0} : (GdkRGBA){1, 1, 1, 1};
webkit_web_view_set_background_color(renderer->web_view, &background);
renderer.window = gtk_window_new(GTK_WINDOW_POPUP); renderer->window = gtk_window_new(GTK_WINDOW_POPUP);
GdkScreen *screen = gdk_screen_get_default(); GdkScreen *screen = gdk_screen_get_default();
GdkVisual *visual = renderer.config.transparent ? gdk_screen_get_rgba_visual(screen) GdkVisual *visual = renderer->config.transparent ? gdk_screen_get_rgba_visual(screen)
: gdk_screen_get_system_visual(screen); : gdk_screen_get_system_visual(screen);
if (visual) if (visual)
gtk_widget_set_visual(renderer.window, visual); gtk_widget_set_visual(renderer->window, visual);
gtk_widget_set_app_paintable(renderer.window, renderer.config.transparent); gtk_widget_set_app_paintable(renderer->window, renderer->config.transparent);
if (renderer.config.transparent) if (renderer->config.transparent)
g_signal_connect(renderer.window, "draw", G_CALLBACK(clear_window), NULL); g_signal_connect(renderer->window, "draw", G_CALLBACK(clear_window), NULL);
gtk_window_set_decorated(GTK_WINDOW(renderer.window), FALSE); gtk_window_set_decorated(GTK_WINDOW(renderer->window), FALSE);
gtk_window_set_resizable(GTK_WINDOW(renderer.window), FALSE); gtk_window_set_resizable(GTK_WINDOW(renderer->window), FALSE);
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(renderer.window), TRUE); gtk_window_set_skip_taskbar_hint(GTK_WINDOW(renderer->window), TRUE);
gtk_window_set_skip_pager_hint(GTK_WINDOW(renderer.window), TRUE); gtk_window_set_skip_pager_hint(GTK_WINDOW(renderer->window), TRUE);
gtk_window_set_accept_focus(GTK_WINDOW(renderer.window), FALSE); gtk_window_set_accept_focus(GTK_WINDOW(renderer->window), FALSE);
gtk_window_set_focus_on_map(GTK_WINDOW(renderer.window), FALSE); gtk_window_set_focus_on_map(GTK_WINDOW(renderer->window), FALSE);
gtk_window_move(GTK_WINDOW(renderer.window), 0, 0); gtk_window_move(GTK_WINDOW(renderer->window), 0, 0);
gtk_window_set_default_size(GTK_WINDOW(renderer.window), (int)renderer.config.width, gtk_window_set_default_size(GTK_WINDOW(renderer->window), (int)renderer->config.width,
(int)renderer.config.height); (int)renderer->config.height);
gtk_widget_set_size_request(GTK_WIDGET(renderer.web_view), (int)renderer.config.width, gtk_widget_set_size_request(GTK_WIDGET(renderer->web_view), (int)renderer->config.width,
(int)renderer.config.height); (int)renderer->config.height);
gtk_container_add(GTK_CONTAINER(renderer.window), GTK_WIDGET(renderer.web_view)); gtk_container_add(GTK_CONTAINER(renderer->window), GTK_WIDGET(renderer->web_view));
gtk_widget_realize(renderer.window); gtk_widget_realize(renderer->window);
GdkWindow *renderer_window = gtk_widget_get_window(renderer.window); GdkWindow *renderer_window = gtk_widget_get_window(renderer->window);
if (!renderer_window || gdk_window_get_window_type(renderer_window) == GDK_WINDOW_OFFSCREEN) { if (!renderer_window || gdk_window_get_window_type(renderer_window) == GDK_WINDOW_OFFSCREEN) {
fprintf(stderr, "GTK did not create the native window required for GPU rendering\n"); renderer_log(renderer, "GTK did not create the native window required for GPU rendering\n");
return 6; goto fail;
} }
cairo_region_t *empty_input = cairo_region_create(); cairo_region_t *empty_input = cairo_region_create();
gdk_window_input_shape_combine_region(renderer_window, empty_input, 0, 0); gdk_window_input_shape_combine_region(renderer_window, empty_input, 0, 0);
cairo_region_destroy(empty_input); cairo_region_destroy(empty_input);
gdk_window_set_opacity(renderer_window, 0.0); gdk_window_set_opacity(renderer_window, 0.0);
gtk_widget_show_all(renderer.window); gtk_widget_show_all(renderer->window);
while (gtk_events_pending()) while (gtk_events_pending())
gtk_main_iteration(); gtk_main_iteration();
if (!initialize_x11_capture(&renderer)) { if (!initialize_x11_capture(renderer)) {
fprintf(stderr, "Cannot initialize XComposite/XDamage/XShm GPU capture\n"); renderer_log(renderer, "Cannot initialize XComposite/XDamage/XShm GPU capture\n");
return 7; goto fail;
} }
int output_flags = fcntl(STDOUT_FILENO, F_GETFL, 0); int output_flags = fcntl(renderer->frame_fd, F_GETFL, 0);
if (output_flags >= 0) if (output_flags >= 0)
fcntl(STDOUT_FILENO, F_SETFL, output_flags | O_NONBLOCK); fcntl(renderer->frame_fd, F_SETFL, output_flags | O_NONBLOCK);
int flags = fcntl(STDIN_FILENO, F_GETFL, 0); int flags = fcntl(renderer->control_fd, F_GETFL, 0);
if (flags >= 0) if (flags >= 0)
fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); fcntl(renderer->control_fd, F_SETFL, flags | O_NONBLOCK);
g_unix_fd_add(STDIN_FILENO, G_IO_IN | G_IO_HUP | G_IO_ERR, control_ready, &renderer); renderer->control_source = g_unix_fd_add(renderer->control_fd,
char *uri = normalize_uri(renderer.config.url); G_IO_IN | G_IO_HUP | G_IO_ERR, control_ready, renderer);
webkit_web_view_load_uri(renderer.web_view, uri); renderer->client_source = g_unix_fd_add(renderer->client_fd,
g_free(uri); G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, client_ready, renderer);
if (pthread_create(&renderer.capture_thread, NULL, capture_loop, &renderer) != 0) { char *uri = normalize_uri(renderer->config.url);
fprintf(stderr, "Cannot start GPU capture thread\n"); char *previous_pulse_sink = g_strdup(g_getenv("PULSE_SINK"));
return 8; char *previous_feature_rank = g_strdup(g_getenv("GST_PLUGIN_FEATURE_RANK"));
if (renderer->config.capture_audio) {
g_setenv("PULSE_SINK", renderer->config.audio_sink, TRUE);
char *audio_feature_rank = previous_feature_rank && *previous_feature_rank
? g_strdup_printf("pipewiresink:NONE,pulsesink:MAX,%s", previous_feature_rank)
: g_strdup("pipewiresink:NONE,pulsesink:MAX");
g_setenv("GST_PLUGIN_FEATURE_RANK", audio_feature_rank, TRUE);
g_free(audio_feature_rank);
} }
renderer.capture_thread_started = true; webkit_web_view_load_uri(renderer->web_view, uri);
fprintf(stderr, "Ready: WebKitGTK %u.%u.%u, %ux%u@%u, GPU DMA-BUF, XComposite/XShm capture\n", if (previous_pulse_sink) {
g_setenv("PULSE_SINK", previous_pulse_sink, TRUE);
g_free(previous_pulse_sink);
} else {
g_unsetenv("PULSE_SINK");
}
if (previous_feature_rank) {
g_setenv("GST_PLUGIN_FEATURE_RANK", previous_feature_rank, TRUE);
g_free(previous_feature_rank);
} else {
g_unsetenv("GST_PLUGIN_FEATURE_RANK");
}
g_free(uri);
if (pthread_create(&renderer->capture_thread, NULL, capture_loop, renderer) != 0) {
renderer_log(renderer, "Cannot start GPU capture thread\n");
goto fail;
}
renderer->capture_thread_started = true;
renderers = g_list_prepend(renderers, renderer);
if (idle_source) {
g_source_remove(idle_source);
idle_source = 0;
}
renderer_log(renderer, "Ready: WebKitGTK %u.%u.%u, %ux%u@%u, zoom %u%%, shared profile, GPU DMA-BUF, XComposite/XShm capture\n",
webkit_get_major_version(), webkit_get_major_version(),
webkit_get_minor_version(), webkit_get_micro_version(), renderer.config.width, webkit_get_minor_version(), webkit_get_micro_version(), renderer->config.width,
renderer.config.height, renderer.config.fps); renderer->config.height, renderer->config.fps, renderer->config.zoom_percent);
fflush(stderr); return renderer;
g_unix_signal_add(SIGTERM, terminate_renderer, &renderer);
g_unix_signal_add(SIGINT, terminate_renderer, &renderer); fail:
destroy_renderer(renderer);
return NULL;
}
static gboolean accept_client(gint server_fd, GIOCondition condition, gpointer unused)
{
(void)unused;
if (condition & (G_IO_HUP | G_IO_ERR | G_IO_NVAL)) {
gtk_main_quit();
return G_SOURCE_REMOVE;
}
for (;;) {
int client_fd = accept4(server_fd, NULL, NULL, SOCK_CLOEXEC);
if (client_fd < 0 && errno == EINTR)
continue;
if (client_fd < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
return G_SOURCE_CONTINUE;
if (client_fd < 0)
return G_SOURCE_CONTINUE;
int descriptors[4] = {-1, -1, -1, -1};
char byte;
struct iovec payload = {.iov_base = &byte, .iov_len = sizeof(byte)};
char control[CMSG_SPACE(sizeof(descriptors))];
struct msghdr message = {0};
message.msg_iov = &payload;
message.msg_iovlen = 1;
message.msg_control = control;
message.msg_controllen = sizeof(control);
ssize_t received = recvmsg(client_fd, &message, 0);
struct cmsghdr *header = received == 1 ? CMSG_FIRSTHDR(&message) : NULL;
if (!header || header->cmsg_level != SOL_SOCKET || header->cmsg_type != SCM_RIGHTS ||
header->cmsg_len != CMSG_LEN(sizeof(descriptors))) {
close(client_fd);
continue;
}
memcpy(descriptors, CMSG_DATA(header), sizeof(descriptors));
for (size_t index = 0; index < 4; index++) {
int flags = fcntl(descriptors[index], F_GETFD);
if (flags >= 0)
fcntl(descriptors[index], F_SETFD, flags | FD_CLOEXEC);
}
create_renderer(client_fd, descriptors);
}
}
static gboolean terminate_daemon(gpointer unused)
{
(void)unused;
gtk_main_quit();
return G_SOURCE_REMOVE;
}
static socklen_t daemon_address(struct sockaddr_un *address, pid_t owner)
{
memset(address, 0, sizeof(*address));
address->sun_family = AF_UNIX;
g_snprintf(address->sun_path + 1, sizeof(address->sun_path) - 1,
"obs-webkit-browser-%lu-%ld", (unsigned long)getuid(), (long)owner);
return (socklen_t)(offsetof(struct sockaddr_un, sun_path) + 1 +
strlen(address->sun_path + 1));
}
static int open_daemon_socket(pid_t owner)
{
int fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
if (fd < 0)
return -1;
struct sockaddr_un address;
socklen_t length = daemon_address(&address, owner);
if (connect(fd, (struct sockaddr *)&address, length) == 0)
return fd;
close(fd);
return -1;
}
static int daemon_main(pid_t owner)
{
if (setsid() < 0)
return 10;
prepare_webkit();
if (!gtk_init_check(NULL, NULL))
return 5;
int server_fd = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (server_fd < 0)
return 10;
struct sockaddr_un address;
socklen_t address_length = daemon_address(&address, owner);
if (bind(server_fd, (struct sockaddr *)&address, address_length) != 0 ||
listen(server_fd, 32) != 0) {
close(server_fd);
return 10;
}
char *data_directory = g_build_filename(g_get_user_data_dir(),
"obs-webkit-browser", "profile", NULL);
char *cache_directory = g_build_filename(g_get_user_cache_dir(),
"obs-webkit-browser", "profile", NULL);
g_mkdir_with_parents(data_directory, 0700);
g_mkdir_with_parents(cache_directory, 0700);
WebKitWebsiteDataManager *data_manager = webkit_website_data_manager_new(
"base-data-directory", data_directory,
"base-cache-directory", cache_directory, NULL);
webkit_website_data_manager_set_persistent_credential_storage_enabled(data_manager, TRUE);
WebKitCookieManager *cookie_manager =
webkit_website_data_manager_get_cookie_manager(data_manager);
char *cookie_file = g_build_filename(data_directory, "cookies.sqlite", NULL);
webkit_cookie_manager_set_persistent_storage(cookie_manager, cookie_file,
WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
webkit_cookie_manager_set_accept_policy(cookie_manager, WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS);
shared_context = webkit_web_context_new_with_website_data_manager(data_manager);
webkit_web_context_set_cache_model(shared_context, WEBKIT_CACHE_MODEL_WEB_BROWSER);
g_object_unref(data_manager);
g_free(cookie_file);
g_free(data_directory);
g_free(cache_directory);
/* The first proxy inherited these descriptors when it forked this daemon.
* Only the SCM_RIGHTS copies accepted above may own an individual view. */
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
close(OWB_SHARED_FRAME_FD);
g_unix_fd_add(server_fd, G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL,
accept_client, NULL);
g_unix_signal_add(SIGTERM, terminate_daemon, NULL);
g_unix_signal_add(SIGINT, terminate_daemon, NULL);
gtk_main(); gtk_main();
atomic_store_explicit(&renderer.stopping, true, memory_order_release); while (renderers)
if (renderer.capture_thread_started) destroy_renderer(renderers->data);
pthread_join(renderer.capture_thread, NULL); if (idle_source) {
destroy_x11_capture(&renderer); g_source_remove(idle_source);
munmap(renderer.shared, renderer.shared_size); idle_source = 0;
free(renderer.config.url); }
free(renderer.config.css); g_object_unref(shared_context);
shared_context = NULL;
close(server_fd);
return 0;
}
static bool pass_view_descriptors(int client_fd)
{
int descriptors[4] = {STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO,
OWB_SHARED_FRAME_FD};
char byte = 1;
struct iovec payload = {.iov_base = &byte, .iov_len = sizeof(byte)};
char control[CMSG_SPACE(sizeof(descriptors))];
memset(control, 0, sizeof(control));
struct msghdr message = {0};
message.msg_iov = &payload;
message.msg_iovlen = 1;
message.msg_control = control;
message.msg_controllen = sizeof(control);
struct cmsghdr *header = CMSG_FIRSTHDR(&message);
header->cmsg_level = SOL_SOCKET;
header->cmsg_type = SCM_RIGHTS;
header->cmsg_len = CMSG_LEN(sizeof(descriptors));
memcpy(CMSG_DATA(header), descriptors, sizeof(descriptors));
return sendmsg(client_fd, &message, MSG_NOSIGNAL) == 1;
}
int main(void)
{
pid_t owner = getppid();
pid_t launched_daemon = 0;
int client_fd = open_daemon_socket(owner);
if (client_fd < 0) {
launched_daemon = fork();
if (launched_daemon < 0)
return 10;
if (launched_daemon == 0)
_exit(daemon_main(owner));
for (unsigned attempt = 0; attempt < 200 && client_fd < 0; attempt++) {
struct timespec pause = {.tv_nsec = 10000000L};
nanosleep(&pause, NULL);
client_fd = open_daemon_socket(owner);
}
}
if (client_fd < 0) {
int status = 0;
if (launched_daemon > 0 && waitpid(launched_daemon, &status, WNOHANG) == launched_daemon &&
WIFEXITED(status))
return WEXITSTATUS(status);
fprintf(stderr, "Cannot start the shared WebKit renderer process\n");
return 10;
}
if (launched_daemon > 0)
waitpid(launched_daemon, NULL, WNOHANG);
if (!pass_view_descriptors(client_fd)) {
fprintf(stderr, "Cannot attach view to the shared WebKit renderer process\n");
close(client_fd);
return 11;
}
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
close(OWB_SHARED_FRAME_FD);
char ignored;
while (recv(client_fd, &ignored, sizeof(ignored), 0) < 0 && errno == EINTR) {
}
close(client_fd);
return 0; return 0;
} }

View File

@@ -1,17 +1,211 @@
#include <obs/obs.h> #include <obs/obs.h>
#include <arpa/inet.h>
#include <errno.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h> #include <unistd.h>
struct audio_probe {
atomic_uint blocks;
atomic_bool signal;
};
static bool source_alpha_is_correct(obs_source_t *source)
{
bool correct = false;
uint8_t solid[4] = {0};
uint8_t half[4] = {0};
uint8_t background[4] = {0};
obs_set_output_source(0, source);
for (unsigned attempt = 0; attempt < 60 && !correct; attempt++) {
usleep(100000);
obs_enter_graphics();
gs_texrender_t *target = gs_texrender_create(GS_BGRA, GS_ZS_NONE);
gs_stagesurf_t *stage = gs_stagesurface_create(64, 64, GS_BGRA);
if (target && stage && gs_texrender_begin(target, 64, 64)) {
struct vec4 clear = {0};
gs_clear(GS_CLEAR_COLOR, &clear, 0.0f, 0);
gs_ortho(0.0f, 64.0f, 0.0f, 64.0f, -100.0f, 100.0f);
bool previous_srgb = gs_set_linear_srgb(true);
obs_source_video_render(source);
gs_set_linear_srgb(previous_srgb);
gs_texrender_end(target);
gs_stage_texture(stage, gs_texrender_get_texture(target));
uint8_t *pixels = NULL;
uint32_t stride = 0;
if (gs_stagesurface_map(stage, &pixels, &stride)) {
memcpy(background, pixels, sizeof(background));
memcpy(solid, pixels + (size_t)24 * stride + 24 * 4, sizeof(solid));
memcpy(half, pixels + (size_t)40 * stride + 40 * 4, sizeof(half));
gs_stagesurface_unmap(stage);
correct = solid[0] < 32 && solid[1] < 40 && solid[2] > 225 &&
solid[3] == 255 && half[0] < 8 && half[1] < 8 &&
half[2] > 120 && half[2] < 136 && half[3] > 120 &&
half[3] < 136 && background[0] == 0 &&
background[1] == 0 && background[2] == 0 &&
background[3] == 0;
}
}
gs_stagesurface_destroy(stage);
gs_texrender_destroy(target);
obs_leave_graphics();
}
obs_set_output_source(0, NULL);
if (!correct)
fprintf(stderr,
"OBS compositing changed browser alpha (solid BGRA=%u,%u,%u,%u; "
"half BGRA=%u,%u,%u,%u; background BGRA=%u,%u,%u,%u)\n",
solid[0], solid[1], solid[2], solid[3], half[0], half[1], half[2],
half[3], background[0], background[1], background[2], background[3]);
return correct;
}
static bool write_all(int fd, const void *data, size_t size)
{
const uint8_t *cursor = data;
while (size) {
ssize_t written = write(fd, cursor, size);
if (written < 0 && errno == EINTR)
continue;
if (written <= 0)
return false;
cursor += (size_t)written;
size -= (size_t)written;
}
return true;
}
static void write_u32_le(uint8_t *destination, uint32_t value)
{
for (unsigned index = 0; index < 4; index++)
destination[index] = (uint8_t)(value >> (index * 8));
}
static void write_u16_le(uint8_t *destination, uint16_t value)
{
destination[0] = (uint8_t)value;
destination[1] = (uint8_t)(value >> 8);
}
static void serve_audio(int server)
{
static const char page[] =
"<!doctype html><style>html,body{margin:0;width:100%;height:100%;background:#123}</style>"
"<audio id='tone' autoplay loop src='/tone.wav'></audio>"
"<script>tone.play().catch(e=>console.error('audio play failed',e))</script>";
const uint32_t frames = 48000;
const uint32_t data_size = frames * 2 * sizeof(int16_t);
uint8_t wav_header[44] = {0};
memcpy(wav_header, "RIFF", 4);
write_u32_le(wav_header + 4, 36 + data_size);
memcpy(wav_header + 8, "WAVEfmt ", 8);
write_u32_le(wav_header + 16, 16);
write_u16_le(wav_header + 20, 1);
write_u16_le(wav_header + 22, 2);
write_u32_le(wav_header + 24, 48000);
write_u32_le(wav_header + 28, 48000 * 2 * sizeof(int16_t));
write_u16_le(wav_header + 32, 2 * sizeof(int16_t));
write_u16_le(wav_header + 34, 16);
memcpy(wav_header + 36, "data", 4);
write_u32_le(wav_header + 40, data_size);
int16_t *samples = malloc(data_size);
if (!samples)
_exit(2);
for (uint32_t frame = 0; frame < frames; frame++) {
int16_t value = (frame / 55) % 2 ? 10000 : -10000;
samples[frame * 2] = value;
samples[frame * 2 + 1] = value;
}
for (;;) {
int client = accept(server, NULL, NULL);
if (client < 0 && errno == EINTR)
continue;
if (client < 0)
_exit(0);
char request[2048] = {0};
ssize_t count = read(client, request, sizeof(request) - 1);
bool wav = count > 0 && strstr(request, "GET /tone.wav ");
char response[512];
size_t body_size = wav ? sizeof(wav_header) + data_size : sizeof(page) - 1;
int length = snprintf(response, sizeof(response),
"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nCache-Control: no-store\r\n"
"Connection: close\r\nContent-Length: %zu\r\n\r\n",
wav ? "audio/wav" : "text/html", body_size);
write_all(client, response, (size_t)length);
if (wav) {
write_all(client, wav_header, sizeof(wav_header));
write_all(client, samples, data_size);
} else {
write_all(client, page, sizeof(page) - 1);
}
close(client);
}
}
static pid_t start_audio_server(char *url, size_t url_size)
{
int server = socket(AF_INET, SOCK_STREAM, 0);
if (server < 0)
return -1;
struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
if (bind(server, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(server, 8) != 0) {
close(server);
return -1;
}
socklen_t size = sizeof(address);
if (getsockname(server, (struct sockaddr *)&address, &size) != 0) {
close(server);
return -1;
}
pid_t child = fork();
if (child == 0)
serve_audio(server);
close(server);
if (child < 0)
return -1;
snprintf(url, url_size, "http://127.0.0.1:%u/", ntohs(address.sin_port));
return child;
}
static void audio_captured(void *opaque, obs_source_t *source,
const struct audio_data *audio, bool muted)
{
(void)source;
(void)muted;
struct audio_probe *probe = opaque;
atomic_fetch_add(&probe->blocks, 1);
if (!audio->data[0])
return;
const float *samples = (const float *)audio->data[0];
for (uint32_t index = 0; index < audio->frames; index++) {
float value = samples[index];
if (value > 0.01f || value < -0.01f) {
atomic_store(&probe->signal, true);
break;
}
}
}
int main(int argc, char **argv) int main(int argc, char **argv)
{ {
if (argc != 4) { if (argc < 4 || argc > 5) {
fprintf(stderr, "usage: %s plugin.so data-directory renderer\n", argv[0]); fprintf(stderr,
"usage: %s plugin.so data-directory renderer [audio-test | alpha-test]\n",
argv[0]);
return 2; return 2;
} }
bool audio_test = argc == 5 && strcmp(argv[4], "audio-test") == 0;
bool alpha_test = argc == 5 && strcmp(argv[4], "alpha-test") == 0;
char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX"; char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX";
if (!mkdtemp(temporary)) { if (!mkdtemp(temporary)) {
perror("mkdtemp"); perror("mkdtemp");
@@ -34,25 +228,74 @@ int main(int argc, char **argv)
rmdir(temporary); rmdir(temporary);
return 77; return 77;
} }
struct obs_audio_info audio_info = {.samples_per_sec = 48000, .speakers = SPEAKERS_STEREO};
if (!obs_reset_audio(&audio_info)) {
obs_shutdown();
return 77;
}
if (alpha_test) {
struct obs_video_info video_info = {
.graphics_module = "libobs-opengl",
.fps_num = 30,
.fps_den = 1,
.base_width = 64,
.base_height = 64,
.output_width = 64,
.output_height = 64,
.output_format = VIDEO_FORMAT_BGRA,
.gpu_conversion = false,
.colorspace = VIDEO_CS_DEFAULT,
.range = VIDEO_RANGE_FULL,
.scale_type = OBS_SCALE_DISABLE,
};
if (obs_reset_video(&video_info) != OBS_VIDEO_SUCCESS) {
obs_shutdown();
return 77;
}
}
char audio_url[128] = {0};
pid_t audio_server = audio_test ? start_audio_server(audio_url, sizeof(audio_url)) : -1;
if (audio_test && audio_server < 0) {
obs_shutdown();
return 77;
}
obs_module_t *module = NULL; obs_module_t *module = NULL;
int result = obs_open_module(&module, argv[1], temporary); int result = obs_open_module(&module, argv[1], temporary);
if (result != MODULE_SUCCESS || !obs_init_module(module)) { if (result != MODULE_SUCCESS || !obs_init_module(module)) {
fprintf(stderr, "module load failed: %d\n", result); fprintf(stderr, "module load failed: %d\n", result);
if (audio_server > 0) {
kill(audio_server, SIGTERM);
waitpid(audio_server, NULL, 0);
}
obs_shutdown(); obs_shutdown();
return 1; return 1;
} }
const char *name = obs_source_get_display_name("webkit_browser_source"); const char *name = obs_source_get_display_name("webkit_browser_source");
uint32_t flags = obs_get_source_output_flags("webkit_browser_source"); uint32_t flags = obs_get_source_output_flags("webkit_browser_source");
bool valid = name && strstr(name, "WebKit") && (flags & OBS_SOURCE_ASYNC_VIDEO) && bool valid = name && strstr(name, "WebKit") && (flags & OBS_SOURCE_ASYNC_VIDEO) &&
(flags & OBS_SOURCE_INTERACTION); (flags & OBS_SOURCE_AUDIO) && (flags & OBS_SOURCE_INTERACTION) &&
(flags & OBS_SOURCE_SRGB);
if (!valid) if (!valid)
fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n", fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n",
name ? name : "(null)", flags); name ? name : "(null)", flags);
obs_data_t *settings = obs_data_create(); obs_data_t *settings = obs_data_create();
obs_data_set_string(settings, "url", "data:text/html,<body style='background:red'></body>"); const char *test_url = "data:text/html,<body style='background:red'></body>";
if (audio_test)
test_url = audio_url;
else if (alpha_test)
test_url = "data:text/html,<body><div id='box'></div></body>";
obs_data_set_string(settings, "url", test_url);
if (alpha_test)
obs_data_set_string(settings, "css",
"html,body{margin:0}#box{position:fixed;left:16px;top:16px;"
"width:16px;height:16px;background:rgb(241,23,17)}"
"#box:after{content:'';position:fixed;left:32px;top:32px;"
"width:16px;height:16px;background:rgba(255,0,0,.5)}");
obs_data_set_int(settings, "width", 64); obs_data_set_int(settings, "width", 64);
obs_data_set_int(settings, "height", 64); obs_data_set_int(settings, "height", 64);
obs_data_set_int(settings, "fps", 10); obs_data_set_int(settings, "fps", 10);
obs_data_set_int(settings, "zoom", alpha_test ? 100 : 175);
obs_data_set_bool(settings, "capture_audio", audio_test);
obs_data_set_bool(settings, "transparent", true); obs_data_set_bool(settings, "transparent", true);
obs_data_set_bool(settings, "hardware_acceleration", false); obs_data_set_bool(settings, "hardware_acceleration", false);
obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings); obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings);
@@ -61,13 +304,53 @@ int main(int argc, char **argv)
fprintf(stderr, "source lifecycle setup failed\n"); fprintf(stderr, "source lifecycle setup failed\n");
valid = false; valid = false;
} }
obs_properties_t *properties = source ? obs_source_properties(source) : NULL;
obs_property_t *zoom = properties ? obs_properties_get(properties, "zoom") : NULL;
obs_property_t *capture_audio_property =
properties ? obs_properties_get(properties, "capture_audio") : NULL;
if (!zoom || obs_property_get_type(zoom) != OBS_PROPERTY_INT ||
obs_property_int_type(zoom) != OBS_NUMBER_SLIDER || obs_property_int_min(zoom) != 25 ||
obs_property_int_max(zoom) != 500 || obs_property_int_step(zoom) != 5) {
fprintf(stderr, "page zoom property is missing or invalid\n");
valid = false;
}
if (!capture_audio_property || obs_property_get_type(capture_audio_property) != OBS_PROPERTY_BOOL ||
!source ||
obs_source_audio_active(source) != audio_test) {
fprintf(stderr, "browser audio capture property or inactive mixer state is invalid\n");
valid = false;
}
if (properties)
obs_properties_destroy(properties);
struct audio_probe probe;
atomic_init(&probe.blocks, 0);
atomic_init(&probe.signal, false);
if (source && audio_test)
obs_source_add_audio_capture_callback(source, audio_captured, &probe);
if (source) { if (source) {
usleep(500000); if (alpha_test && !source_alpha_is_correct(source))
valid = false;
unsigned attempts = audio_test ? 100 : 5;
for (unsigned attempt = 0; attempt < attempts &&
(!audio_test || !atomic_load(&probe.signal)); attempt++)
usleep(100000);
if (audio_test) {
obs_source_remove_audio_capture_callback(source, audio_captured, &probe);
if (!atomic_load(&probe.signal)) {
fprintf(stderr, "browser audio did not reach the OBS source (blocks=%u)\n",
atomic_load(&probe.blocks));
valid = false;
}
}
obs_source_release(source); obs_source_release(source);
} }
obs_shutdown(); obs_shutdown();
unlink(renderer_link); unlink(renderer_link);
unlink(locale_link); unlink(locale_link);
rmdir(temporary); rmdir(temporary);
if (audio_server > 0) {
kill(audio_server, SIGTERM);
waitpid(audio_server, NULL, 0);
}
return valid ? 0 : 1; return valid ? 0 : 1;
} }

View File

@@ -5,9 +5,12 @@
int main(void) int main(void)
{ {
assert(sizeof(struct owb_config_header) == 36); assert(sizeof(struct owb_config_header) == 108);
assert(offsetof(struct owb_config_header, zoom_percent) == 20);
assert(offsetof(struct owb_config_header, audio_sink) == 44);
assert(sizeof(struct owb_command) == 100); 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(offsetof(struct owb_command, text) == 36);
assert(OWB_CONFIG_MAGIC != OWB_COMMAND_MAGIC); assert(OWB_CONFIG_MAGIC != OWB_COMMAND_MAGIC);
return 0; return 0;

View File

@@ -69,7 +69,7 @@ int main(int argc, char **argv)
shared->width = width; shared->width = width;
shared->height = height; shared->height = height;
shared->stride = width * 4; 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_t actions;
posix_spawn_file_actions_init(&actions); posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO); posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO);
@@ -109,6 +109,7 @@ int main(int argc, char **argv)
struct owb_config_header config = { struct owb_config_header config = {
.magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION, .magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION,
.width = width, .height = height, .fps = requested_fps, .transparent = 1, .width = width, .height = height, .fps = requested_fps, .transparent = 1,
.zoom_percent = 100,
.hardware_acceleration = 1, .hardware_acceleration = 1,
.url_length = sizeof(url) - 1, .css_length = (uint32_t)css_length, .url_length = sizeof(url) - 1, .css_length = (uint32_t)css_length,
}; };

View File

@@ -56,12 +56,13 @@ int main(int argc, char **argv)
{ {
if (argc < 2 || argc > 4) { if (argc < 2 || argc > 4) {
fprintf(stderr, fprintf(stderr,
"usage: %s /path/to/obs-webkit-renderer [opaque | opaque-url URL]\n", "usage: %s /path/to/obs-webkit-renderer [opaque | zoom | opaque-url URL]\n",
argv[0]); argv[0]);
return 2; return 2;
} }
bool external_url = argc == 4 && strcmp(argv[2], "opaque-url") == 0; bool external_url = argc == 4 && strcmp(argv[2], "opaque-url") == 0;
bool opaque = external_url || (argc == 3 && strcmp(argv[2], "opaque") == 0); bool zoom = argc == 3 && strcmp(argv[2], "zoom") == 0;
bool opaque = external_url || zoom || (argc == 3 && strcmp(argv[2], "opaque") == 0);
int control[2]; int control[2];
int frames[2]; int frames[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) || pipe(frames)) { if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) || pipe(frames)) {
@@ -84,7 +85,7 @@ int main(int argc, char **argv)
shared->width = width; shared->width = width;
shared->height = height; shared->height = height;
shared->stride = width * 4; 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_t actions;
posix_spawn_file_actions_init(&actions); posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO); posix_spawn_file_actions_adddup2(&actions, control[1], STDIN_FILENO);
@@ -103,30 +104,43 @@ int main(int argc, char **argv)
const char internal_url[] = const char internal_url[] =
"data:text/html,<body style='background:rgb(3,5,7)'><div id='box'></div></body>"; "data:text/html,<body style='background:rgb(3,5,7)'><div id='box'></div></body>";
const char transparent_css[] = "html,body{margin:0}" const char transparent_css[] =
"#box{position:fixed;inset:16px;background:rgba(255,0,0,.5)}"; "html,body{margin:0}"
"#box{position:fixed;left:16px;top:16px;width:16px;height:16px;"
"background:rgb(241,23,17)}"
"#box:after{content:'';position:fixed;left:32px;top:32px;width:16px;"
"height:16px;background:rgba(255,0,0,.5)}";
const char opaque_css[] = "html,body{margin:0;background:rgb(7,19,31)!important}" const char opaque_css[] = "html,body{margin:0;background:rgb(7,19,31)!important}"
"#box{position:fixed;left:16px;top:16px;width:32px;height:32px;" "#box{position:fixed;left:16px;top:16px;width:32px;height:32px;"
"background:rgb(241,23,17);transform:translateZ(0);filter:blur(.1px)}"; "background:rgb(241,23,17);transform:translateZ(0);filter:blur(.1px)}";
const char zoom_css[] = "html,body{margin:0;background:rgb(7,19,31)!important}"
"#box{position:fixed;left:0;top:0;width:10px;height:10px;"
"background:rgb(241,23,17)}";
const char *url = external_url ? argv[3] : internal_url; const char *url = external_url ? argv[3] : internal_url;
const char *css = external_url ? "" : opaque ? opaque_css : transparent_css; const char *css = external_url ? "" : zoom ? zoom_css : opaque ? opaque_css : transparent_css;
const char *audio_sink = getenv("OWB_TEST_AUDIO_SINK");
struct owb_config_header config = { struct owb_config_header config = {
.magic = OWB_CONFIG_MAGIC, .magic = OWB_CONFIG_MAGIC,
.version = OWB_PROTOCOL_VERSION, .version = OWB_PROTOCOL_VERSION,
.width = width, .width = width,
.height = height, .height = height,
.fps = 10, .fps = 10,
.zoom_percent = zoom ? 200 : 100,
.capture_audio = audio_sink && *audio_sink,
.transparent = opaque ? 0 : 1, .transparent = opaque ? 0 : 1,
/* GPU rendering must still produce an invisible captured frame. */ /* GPU rendering must still produce an invisible captured frame. */
.hardware_acceleration = 1, .hardware_acceleration = 1,
.url_length = (uint32_t)strlen(url), .url_length = (uint32_t)strlen(url),
.css_length = (uint32_t)strlen(css), .css_length = (uint32_t)strlen(css),
}; };
if (audio_sink)
snprintf(config.audio_sink, sizeof(config.audio_sink), "%s", audio_sink);
bool sent = write_exact(control[0], &config, sizeof(config)) && bool sent = write_exact(control[0], &config, sizeof(config)) &&
write_exact(control[0], url, strlen(url)) && write_exact(control[0], url, strlen(url)) &&
write_exact(control[0], css, strlen(css)); write_exact(control[0], css, strlen(css));
uint8_t last_bgra[4] = {0}; uint8_t last_bgra[4] = {0};
uint8_t last_corner_bgra[4] = {0}; uint8_t last_corner_bgra[4] = {0};
uint8_t last_half_bgra[4] = {0};
bool found_red = false; bool found_red = false;
int max_attempts = external_url ? 120 : 60; int max_attempts = external_url ? 120 : 60;
for (int attempt = 0; sent && attempt < max_attempts; attempt++) { for (int attempt = 0; sent && attempt < max_attempts; attempt++) {
@@ -158,14 +172,28 @@ int main(int argc, char **argv)
nonuniform ? "content" : "uniform"); nonuniform ? "content" : "uniform");
continue; continue;
} }
size_t center = ((size_t)(height / 2) * width + width / 2) * 4; size_t center = zoom ? ((size_t)5 * width + 15) * 4
: opaque ? ((size_t)(height / 2) * width + width / 2) * 4
: ((size_t)24 * width + 24) * 4;
memcpy(last_bgra, frame + center, sizeof(last_bgra)); memcpy(last_bgra, frame + center, sizeof(last_bgra));
memcpy(last_corner_bgra, frame, sizeof(last_corner_bgra)); size_t corner = zoom ? ((size_t)5 * width + 25) * 4 : 0;
bool expected = opaque memcpy(last_corner_bgra, frame + corner, sizeof(last_corner_bgra));
if (!zoom && !opaque) {
size_t half = ((size_t)40 * width + 40) * 4;
memcpy(last_half_bgra, frame + half, sizeof(last_half_bgra));
}
bool expected = zoom
? last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 210 &&
last_bgra[3] == 255 && last_corner_bgra[0] > 20 &&
last_corner_bgra[1] < 32 && last_corner_bgra[2] < 32 &&
last_corner_bgra[3] == 255
: opaque
? last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 210 && ? last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 210 &&
last_bgra[3] == 255 last_bgra[3] == 255
: last_bgra[0] < 8 && last_bgra[1] < 8 && last_bgra[2] > 248 && : last_bgra[0] < 32 && last_bgra[1] < 40 && last_bgra[2] > 225 &&
last_bgra[3] > 120 && last_bgra[3] < 136 && last_bgra[3] == 255 && last_half_bgra[0] < 8 &&
last_half_bgra[1] < 8 && last_half_bgra[2] > 248 &&
last_half_bgra[3] > 120 && last_half_bgra[3] < 136 &&
last_corner_bgra[0] == 0 && last_corner_bgra[1] == 0 && last_corner_bgra[0] == 0 && last_corner_bgra[1] == 0 &&
last_corner_bgra[2] == 0 && last_corner_bgra[3] == 0; last_corner_bgra[2] == 0 && last_corner_bgra[3] == 0;
if (expected) { if (expected) {
@@ -191,9 +219,12 @@ int main(int argc, char **argv)
} }
fprintf(stderr, fprintf(stderr,
"renderer did not produce the expected %s red BGRA frame " "renderer did not produce the expected %s red BGRA frame "
"(center BGRA=%u,%u,%u,%u; corner BGRA=%u,%u,%u,%u)\n", "(center BGRA=%u,%u,%u,%u; half BGRA=%u,%u,%u,%u; "
opaque ? "opaque" : "transparent", last_bgra[0], last_bgra[1], "corner BGRA=%u,%u,%u,%u)\n",
last_bgra[2], last_bgra[3], last_corner_bgra[0], last_corner_bgra[1], zoom ? "200%-zoomed" : opaque ? "opaque" : "transparent",
last_bgra[0], last_bgra[1],
last_bgra[2], last_bgra[3], last_half_bgra[0], last_half_bgra[1],
last_half_bgra[2], last_half_bgra[3], last_corner_bgra[0], last_corner_bgra[1],
last_corner_bgra[2], last_corner_bgra[3]); last_corner_bgra[2], last_corner_bgra[3]);
return 1; return 1;
} }

203
tests/shared-profile-test.c Normal file
View File

@@ -0,0 +1,203 @@
#include "protocol.h"
#include <arpa/inet.h>
#include <errno.h>
#include <poll.h>
#include <signal.h>
#include <spawn.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
extern char **environ;
struct view {
pid_t pid;
int control;
int frames;
int shared_fd;
struct owb_shared_frames *shared;
size_t shared_size;
};
static bool write_all(int fd, const void *data, size_t size)
{
const uint8_t *cursor = data;
while (size) {
ssize_t count = write(fd, cursor, size);
if (count < 0 && errno == EINTR)
continue;
if (count <= 0)
return false;
cursor += (size_t)count;
size -= (size_t)count;
}
return true;
}
static void serve_pages(int server)
{
static const char page[] =
"<!doctype html><style>html,body{margin:0;width:100%;height:100%;background:rgb(2,3,5)}</style>"
"<script>const p=new URL(location.href),t=p.searchParams.get('token'),"
"k='owb-shared-profile-test',b=new BroadcastChannel(k);"
"if(p.pathname==='/writer'){setInterval(()=>{localStorage.setItem(k,t);b.postMessage(t)},100)}"
"else{let heard=false;b.onmessage=e=>{if(e.data===t)heard=true};"
"setInterval(()=>{if(heard&&localStorage.getItem(k)===t)"
"document.body.style.background='rgb(17,231,43)'},50)}</script>";
char response[2048];
int length = snprintf(response, sizeof(response),
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nCache-Control: no-store\r\n"
"Connection: close\r\nContent-Length: %zu\r\n\r\n%s",
strlen(page), page);
for (;;) {
int client = accept(server, NULL, NULL);
if (client < 0 && errno == EINTR)
continue;
if (client < 0)
_exit(0);
char request[1024];
ssize_t ignored = read(client, request, sizeof(request));
(void)ignored;
write_all(client, response, (size_t)length);
close(client);
}
}
static bool start_view(struct view *view, const char *renderer, const char *url)
{
memset(view, 0, sizeof(*view));
view->pid = 0;
view->control = view->frames = view->shared_fd = -1;
int control[2] = {-1, -1};
int frames[2] = {-1, -1};
if (socketpair(AF_UNIX, SOCK_STREAM, 0, control) != 0 || pipe(frames) != 0)
return false;
const uint32_t width = 64, height = 64;
view->shared_size = owb_shared_size(width, height);
view->shared_fd = memfd_create("shared-profile-test", MFD_CLOEXEC);
if (view->shared_fd < 0 || ftruncate(view->shared_fd, (off_t)view->shared_size) != 0)
return false;
view->shared = mmap(NULL, view->shared_size, PROT_READ | PROT_WRITE, MAP_SHARED,
view->shared_fd, 0);
if (view->shared == MAP_FAILED)
return false;
memset(view->shared, 0, sizeof(*view->shared));
view->shared->magic = OWB_SHARED_MAGIC;
view->shared->version = OWB_PROTOCOL_VERSION;
view->shared->width = width;
view->shared->height = height;
view->shared->stride = width * 4;
view->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);
posix_spawn_file_actions_adddup2(&actions, frames[1], STDOUT_FILENO);
posix_spawn_file_actions_adddup2(&actions, view->shared_fd, OWB_SHARED_FRAME_FD);
char *arguments[] = {(char *)renderer, NULL};
int error = posix_spawn(&view->pid, renderer, &actions, NULL, arguments, environ);
posix_spawn_file_actions_destroy(&actions);
close(control[1]);
close(frames[1]);
if (error)
return false;
view->control = control[0];
view->frames = frames[0];
struct owb_config_header config = {
.magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION,
.width = width, .height = height, .fps = 20, .transparent = 0,
.zoom_percent = 100,
.hardware_acceleration = 1, .url_length = (uint32_t)strlen(url), .css_length = 0,
};
return write_all(view->control, &config, sizeof(config)) &&
write_all(view->control, url, strlen(url));
}
static int stop_view(struct view *view)
{
int status = 0;
if (view->control >= 0) {
shutdown(view->control, SHUT_RDWR);
close(view->control);
}
if (view->frames >= 0)
close(view->frames);
if (view->shared && view->shared != MAP_FAILED)
munmap(view->shared, view->shared_size);
if (view->shared_fd >= 0)
close(view->shared_fd);
if (view->pid > 0) {
kill(view->pid, SIGTERM);
while (waitpid(view->pid, &status, 0) < 0 && errno == EINTR) {
}
}
return status;
}
int main(int argc, char **argv)
{
if (argc != 2)
return 2;
int server = socket(AF_INET, SOCK_STREAM, 0);
int enabled = 1;
setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled));
struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
if (server < 0 || bind(server, (struct sockaddr *)&address, sizeof(address)) != 0 ||
listen(server, 16) != 0)
return 77;
socklen_t address_size = sizeof(address);
if (getsockname(server, (struct sockaddr *)&address, &address_size) != 0)
return 2;
pid_t http_server = fork();
if (http_server == 0)
serve_pages(server);
close(server);
char writer_url[256], reader_url[256];
unsigned port = ntohs(address.sin_port);
snprintf(writer_url, sizeof(writer_url), "http://127.0.0.1:%u/writer?token=%ld", port,
(long)getpid());
snprintf(reader_url, sizeof(reader_url), "http://127.0.0.1:%u/reader?token=%ld", port,
(long)getpid());
struct view writer, reader;
bool started_writer = start_view(&writer, argv[1], writer_url);
bool started_reader = started_writer && start_view(&reader, argv[1], reader_url);
bool shared = false;
for (unsigned attempt = 0; started_reader && attempt < 200; attempt++) {
struct pollfd item = {.fd = reader.frames, .events = POLLIN};
if (poll(&item, 1, 100) <= 0)
continue;
uint8_t slot;
if (read(reader.frames, &slot, sizeof(slot)) != 1 || slot >= OWB_SHARED_FRAME_SLOTS)
continue;
uint8_t *frame = owb_shared_slot(reader.shared, slot);
size_t center = ((size_t)32 * 64 + 32) * 4;
uint8_t *bgra = frame + center;
if (bgra[0] < 55 && bgra[1] > 210 && bgra[2] < 35 && bgra[3] == 255) {
shared = true;
break;
}
}
int reader_status = started_reader ? stop_view(&reader) : 0;
int writer_status = started_writer ? stop_view(&writer) : 0;
kill(http_server, SIGTERM);
waitpid(http_server, NULL, 0);
if (!started_writer || !started_reader)
return 2;
if ((WIFEXITED(reader_status) && WEXITSTATUS(reader_status) == 5) ||
(WIFEXITED(writer_status) && WEXITSTATUS(writer_status) == 5))
return 77;
if (!shared) {
fprintf(stderr, "two renderer views did not share localStorage and BroadcastChannel\n");
return 1;
}
return 0;
}