Add portal window capture backend

This commit is contained in:
scootz 2026-05-01 13:05:49 +01:00
parent ee0cb2bc8a
commit 3216f4a7a2
9 changed files with 1924 additions and 193 deletions

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ wheels/
.venv
kwin_capture_screen
portal_capture_frame
*.moc

View file

@ -1,15 +1,39 @@
CXX ?= g++
PKG_CONFIG ?= pkg-config
PREFIX ?= /usr/local
BIN_DIR ?= $(PREFIX)/bin
APPLICATIONS_DIR ?= $(PREFIX)/share/applications
SYSTEM_HELPER := $(BIN_DIR)/reforger-kwin-capture
DESKTOP_FILE := org.codex.reforger-kwin-capture.desktop
QT_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus)
QT_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus)
PORTAL_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0)
PORTAL_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0)
MOC ?= /usr/lib/qt6/moc
.PHONY: all clean
.PHONY: all clean install-kwin-auth uninstall-kwin-auth
all: kwin_capture_screen
all: kwin_capture_screen portal_capture_frame
kwin_capture_screen: kwin_capture_screen.cpp
$(CXX) -std=c++17 -O2 -Wall -Wextra $(QT_CFLAGS) $< -o $@ $(QT_LIBS)
kwin_capture_screen: kwin_capture_screen.cpp kwin_capture_screen.moc
$(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(QT_CFLAGS) $< -o $@ $(QT_LIBS)
kwin_capture_screen.moc: kwin_capture_screen.cpp
$(MOC) $< -o $@
portal_capture_frame: portal_capture_frame.cpp portal_capture_frame.moc
$(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(PORTAL_CFLAGS) $< -o $@ $(PORTAL_LIBS)
portal_capture_frame.moc: portal_capture_frame.cpp
$(MOC) $< -o $@
install-kwin-auth: kwin_capture_screen $(DESKTOP_FILE)
install -Dm755 kwin_capture_screen "$(DESTDIR)$(SYSTEM_HELPER)"
install -Dm644 $(DESKTOP_FILE) "$(DESTDIR)$(APPLICATIONS_DIR)/$(DESKTOP_FILE)"
uninstall-kwin-auth:
rm -f "$(DESTDIR)$(SYSTEM_HELPER)" "$(DESTDIR)$(APPLICATIONS_DIR)/$(DESKTOP_FILE)"
clean:
rm -f kwin_capture_screen
rm -f kwin_capture_screen kwin_capture_screen.moc portal_capture_frame portal_capture_frame.moc

277
README.md
View file

@ -1,76 +1,127 @@
# Anti Prestige Tool
Prototype queue-position reader for Arma Reforger.
Queue-position reader for Arma Reforger on Linux.
## Current State
The tool captures the Reforger queue screen, crops the queue-number region, and reads the orange UI digits with a small built-in matcher. It does not use Tesseract, OpenCV, or Pillow.
The working script is:
## Install
Install system dependencies:
```bash
python3 reforger_queue_read.py
# Arch example
sudo pacman -S --needed base-devel uv qt6-base gstreamer gst-plugins-base gst-plugin-pipewire imagemagick xdg-desktop-portal
```
It reads the queue number by:
1. Cropping the queue-number region.
2. Thresholding orange UI pixels.
3. Splitting connected digit components.
4. Matching digits against synthetic `Roboto-Condensed` templates.
No Tesseract/OpenCV/Pillow dependency is currently required.
## Dataset Validation
Dataset:
You also need a portal backend for your desktop:
```text
/home/scootz/Pictures/codex-dataset
KDE: xdg-desktop-portal-kde
GNOME: xdg-desktop-portal-gnome
wlr: xdg-desktop-portal-wlr
```
Validation command:
Build the local helpers:
```bash
python3 reforger_queue_read.py --dataset /home/scootz/Pictures/codex-dataset --expect-filenames
make
```
Current result:
No sudo install is required for the default Portal/PipeWire capture path.
```text
summary: 14 passed, 0 failed
```
## Use
The default crop is derived from this 2560x1440 reference:
```text
reference-size: 2560x1440
reference-crop: x=1050 y=620 width=100 height=60
scale-mode: width
```
`scale-mode=width` means the crop scales uniformly from the screenshot width. This handles the current dataset better than independent width/height scaling because the dataset images are slightly shorter than 1440 pixels, while the UI coordinates still match a 1440p layout.
## Real Screenshot Calibration
Dataset:
```text
datasets/scootz-dataset
```
Files:
```text
1920x1080.png
2160x1440.png # actual image size is 2560x1440
```
Validation command:
Preferred command:
```bash
python3 reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop --debug
uv run reforger_queue_read.py --portal-window --show-crop --debug
```
Current result:
On first run, your desktop should show a window-sharing picker. Select the Arma Reforger window. If the portal grants persistence, the tool stores a restore token here:
```text
~/.local/state/anti-prestige-tool/portal-window-restore-token
```
Later runs reuse that token:
```bash
uv run reforger_queue_read.py --portal-window --show-crop --debug
```
If the saved window selection is wrong or stale:
```bash
uv run reforger_queue_read.py --portal-window --portal-reselect --show-crop --debug
```
Save the captured input image while debugging:
```bash
uv run reforger_queue_read.py --portal-window --save-input /tmp/apt-portal-window.png --show-crop --debug
```
## Optional KDE/KWin Mode
KWin mode can automatically find the Arma window by KWin metadata and can rebind after the game restarts. It is KDE-specific and requires installing an authorized helper.
Install the helper:
```bash
sudo make install-kwin-auth
kbuildsycoca6
```
Run:
```bash
uv run reforger_queue_read.py --kwin-window auto --show-crop --debug
```
If auto matching is ambiguous, list windows and pass the PID or internal id:
```bash
uv run reforger_queue_read.py --list-kwin-windows
uv run reforger_queue_read.py --kwin-window 53561 --show-crop --debug
uv run reforger_queue_read.py --kwin-window-id '{77a38955-a827-479c-971b-af8de226ac7b}' --show-crop --debug
```
The auth install places:
```text
/usr/local/bin/reforger-kwin-capture
/usr/local/share/applications/org.codex.reforger-kwin-capture.desktop
```
The desktop file must be in a root-owned application directory because KWin does not trust user-writable desktop entries for restricted screenshot interfaces.
## Other Inputs
Read an existing screenshot:
```bash
uv run reforger_queue_read.py --image /path/to/screenshot.png --show-crop --debug
```
Use Spectacle/KDE screen capture:
```bash
uv run reforger_queue_read.py --capture --screen DP-3 --show-crop --debug
```
Use an OBS scene screenshot:
```bash
uv run reforger_queue_read.py --obs-scene APT --show-crop --debug
```
Validate the included dataset:
```bash
uv run reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop
```
Expected current output:
```text
datasets/scootz-dataset/1920x1080.png crop=788,465,75,45
@ -79,124 +130,16 @@ datasets/scootz-dataset/2160x1440.png crop=1050,620,100,60
datasets/scootz-dataset/2160x1440.png 24
```
Measured digit box positions:
## Notes
Default crop reference:
```text
1440p digit bbox abs=(1080,636)-(1118,666)
1440p digit bbox rel=(0.421875,0.441667)-(0.436719,0.462500)
1080p digit bbox abs=(810,477)-(838,500)
1080p digit bbox rel=(0.421875,0.441667)-(0.436458,0.462963)
reference-size: 2560x1440
reference-crop: 1050,620,100,60
scale-mode: width
```
The match is exact enough to use width-scaled coordinates for normal 16:9 1080p and 1440p users.
Portal/PipeWire is easiest to install, but restore behavior depends on the desktop portal. If the selected window disappears after an Arma restart, the portal may show the picker again.
Default search crop relative to a 2560x1440 screen:
```text
x=1050 / 2560 = 0.410156
y=620 / 1440 = 0.430556
w=100 / 2560 = 0.039063
h=60 / 1440 = 0.041667
```
Resolved default crop examples:
```text
2560x1440 -> x=1050 y=620 width=100 height=60
1920x1080 -> x=788 y=465 width=75 height=45
```
## Single-Monitor Capture Direction
The old approach captured the whole KDE desktop across all screens. That is not ideal.
List available KWin outputs:
```bash
python3 reforger_queue_read.py --list-screens
```
Example output:
```text
DP-2 enabled 0,360,1920x1080
DP-3 enabled 1920,0,2560x1440
```
Preferred live usage is to pass the screen containing Reforger explicitly:
```bash
python3 reforger_queue_read.py --capture --screen DP-3 --show-crop --debug
```
The tool first tries KWin's screen-specific screenshot API. On this machine KWin currently rejects that direct API for an untrusted process, so the tool falls back to:
1. Capturing the full Spectacle desktop.
2. Cropping to the requested KWin output geometry.
3. Applying the queue-number crop inside that monitor image.
This still avoids the "wrong current monitor" problem. It does not yet avoid the temporary full-desktop screenshot in the fallback path.
The script also supports Spectacle's current-monitor capture:
```bash
python3 reforger_queue_read.py --capture
```
This uses:
```bash
spectacle --background --current --nonotify --output <tempfile>
```
That should capture only one monitor. For this to work reliably, Reforger should be focused or the relevant monitor should be the current KDE monitor.
Whole-desktop capture is still available for fallback:
```bash
python3 reforger_queue_read.py --capture --capture-mode fullscreen
```
Live DP-3 test result from 2026-05-01:
```text
crop=1050,620,100,60
19
```
## Useful Commands
Read a full single-monitor screenshot:
```bash
python3 reforger_queue_read.py --image /path/to/screenshot.png --debug
```
Show the crop selected for a screenshot:
```bash
python3 reforger_queue_read.py --image /path/to/screenshot.png --show-crop --debug
```
Read a full screenshot with an explicit crop:
```bash
python3 reforger_queue_read.py --image /path/to/screenshot.png --crop 1050,620,100,60 --debug
```
Read already-cropped number images:
```bash
python3 reforger_queue_read.py --dataset /path/to/cropped-digits --cropped --expect-filenames
```
## Next Step
Get real full screenshots from the game on the actual monitor setup and validate:
```bash
python3 reforger_queue_read.py --image /path/to/real-full-screenshot.png --debug
```
If the default crop misses, use ImageMagick to crop around the queue value, then update `--reference-crop`.
KWin mode is the best KDE automation path, but it has the extra authorization install step.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -4,34 +4,90 @@
#include <QDBusInterface>
#include <QDBusReply>
#include <QDBusUnixFileDescriptor>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryFile>
#include <QTextStream>
#include <QTimer>
#include <QVariantMap>
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QTextStream err(stderr);
#include <unistd.h>
if (argc != 3) {
err << "usage: " << argv[0] << " OUTPUT_NAME OUTPUT_FILE\n";
return 64;
class WindowReceiver : public QObject
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.codex.ReforgerQueueKWinProbe")
public:
QString json;
QString error;
bool done = false;
public slots:
void WindowsJson(const QString &payload)
{
json = payload;
done = true;
QCoreApplication::quit();
}
const QString outputName = QString::fromLocal8Bit(argv[1]);
const QString outputPath = QString::fromLocal8Bit(argv[2]);
void Error(const QString &message)
{
error = message;
done = true;
QCoreApplication::quit();
}
};
QDBusConnection sessionBus()
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (bus.isConnected()) {
return bus;
}
QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR");
if (runtimeDir.isEmpty()) {
runtimeDir = QStringLiteral("/run/user/%1").arg(getuid());
}
return QDBusConnection::connectToBus(
QStringLiteral("unix:path=%1/bus").arg(runtimeDir),
QStringLiteral("reforger-queue-session"));
}
QString jsString(const QString &value)
{
QJsonArray array;
array.append(value);
QByteArray encoded = QJsonDocument(array).toJson(QJsonDocument::Compact);
encoded.chop(1);
encoded.remove(0, 1);
return QString::fromUtf8(encoded);
}
int capture(const QString &method, const QString &target, const QString &outputPath, QTextStream &out, QTextStream &err)
{
QFile file(outputPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
err << "failed to open output file: " << file.errorString() << "\n";
return 1;
}
QDBusConnection bus = sessionBus();
if (!bus.isConnected()) {
err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n";
return 1;
}
QDBusInterface iface(
QStringLiteral("org.kde.KWin"),
QStringLiteral("/org/kde/KWin/ScreenShot2"),
QStringLiteral("org.kde.KWin.ScreenShot2"),
QDBusConnection::sessionBus());
bus);
if (!iface.isValid()) {
err << "failed to connect to KWin ScreenShot2: "
@ -41,19 +97,238 @@ int main(int argc, char *argv[])
QVariantMap options;
QDBusUnixFileDescriptor fd(file.handle());
QDBusReply<QVariantMap> reply = iface.call(
QStringLiteral("CaptureScreen"),
outputName,
options,
QVariant::fromValue(fd));
QDBusReply<QVariantMap> reply = iface.call(method, target, options, QVariant::fromValue(fd));
file.close();
if (!reply.isValid()) {
err << "CaptureScreen failed for " << outputName << ": "
err << method << " failed for " << target << ": "
<< reply.error().name() << ": " << reply.error().message() << "\n";
return 1;
}
QJsonObject resultJson;
const QVariantMap resultMap = reply.value();
for (auto it = resultMap.constBegin(); it != resultMap.constEnd(); ++it) {
resultJson.insert(it.key(), QJsonValue::fromVariant(it.value()));
}
out << QJsonDocument(resultJson).toJson(QJsonDocument::Compact) << "\n";
return 0;
}
QString windowProbeScript(const QString &service, const QString &path, const QString &interface)
{
return QStringLiteral(R"JS(
function safeString(value) {
if (value === undefined || value === null) {
return "";
}
return String(value);
}
function safeNumber(value) {
var number = Number(value);
return isNaN(number) ? 0 : number;
}
function safeBool(value) {
return Boolean(value);
}
function prop(object, name, fallback) {
try {
if (object[name] === undefined || object[name] === null) {
return fallback;
}
return object[name];
} catch (error) {
return fallback;
}
}
function rect(value) {
if (!value) {
return {"x": 0, "y": 0, "width": 0, "height": 0};
}
return {
"x": Math.round(safeNumber(prop(value, "x", 0))),
"y": Math.round(safeNumber(prop(value, "y", 0))),
"width": Math.round(safeNumber(prop(value, "width", 0))),
"height": Math.round(safeNumber(prop(value, "height", 0)))
};
}
function outputName(window) {
try {
if (window.output && window.output.name) {
return String(window.output.name);
}
} catch (error) {
}
return "";
}
function windowInfo(window) {
var geometry = rect(prop(window, "frameGeometry", prop(window, "bufferGeometry", null)));
return {
"internalId": safeString(prop(window, "internalId", "")),
"pid": safeNumber(prop(window, "pid", 0)),
"caption": safeString(prop(window, "caption", "")),
"resourceClass": safeString(prop(window, "resourceClass", "")),
"resourceName": safeString(prop(window, "resourceName", "")),
"windowRole": safeString(prop(window, "windowRole", "")),
"desktopFileName": safeString(prop(window, "desktopFileName", "")),
"output": outputName(window),
"frameGeometry": geometry,
"normalWindow": safeBool(prop(window, "normalWindow", false)),
"fullScreen": safeBool(prop(window, "fullScreen", false)),
"minimized": safeBool(prop(window, "minimized", false)),
"hidden": safeBool(prop(window, "hidden", false)),
"keepAbove": safeBool(prop(window, "keepAbove", false)),
"skipTaskbar": safeBool(prop(window, "skipTaskbar", false))
};
}
try {
var windows = workspace.windowList().map(windowInfo);
callDBus(%1, %2, %3, "WindowsJson", JSON.stringify(windows));
} catch (error) {
callDBus(%1, %2, %3, "Error", safeString(error && error.stack ? error.stack : error));
}
)JS")
.arg(jsString(service), jsString(path), jsString(interface));
}
int listWindows(QCoreApplication &app, QTextStream &out, QTextStream &err)
{
QDBusConnection bus = sessionBus();
if (!bus.isConnected()) {
err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n";
return 1;
}
const QString service = QStringLiteral("org.codex.ReforgerQueueKWinProbe%1").arg(QCoreApplication::applicationPid());
const QString path = QStringLiteral("/Probe");
const QString interface = QStringLiteral("org.codex.ReforgerQueueKWinProbe");
WindowReceiver receiver;
if (!bus.registerService(service)) {
err << "failed to register D-Bus service " << service << ": " << bus.lastError().message() << "\n";
return 1;
}
if (!bus.registerObject(path, &receiver, QDBusConnection::ExportAllSlots)) {
err << "failed to register D-Bus object " << path << ": " << bus.lastError().message() << "\n";
bus.unregisterService(service);
return 1;
}
QTemporaryFile scriptFile;
scriptFile.setFileTemplate(QDir::tempPath() + QStringLiteral("/reforger-kwin-window-probe-XXXXXX.js"));
if (!scriptFile.open()) {
err << "failed to create temporary KWin script: " << scriptFile.errorString() << "\n";
bus.unregisterObject(path);
bus.unregisterService(service);
return 1;
}
scriptFile.write(windowProbeScript(service, path, interface).toUtf8());
scriptFile.close();
const QString pluginName = QStringLiteral("reforger-queue-window-probe-%1").arg(QCoreApplication::applicationPid());
QDBusInterface scripting(
QStringLiteral("org.kde.KWin"),
QStringLiteral("/Scripting"),
QStringLiteral("org.kde.kwin.Scripting"),
bus);
if (!scripting.isValid()) {
err << "failed to connect to KWin scripting: " << scripting.lastError().message() << "\n";
bus.unregisterObject(path);
bus.unregisterService(service);
return 1;
}
QDBusReply<int> loadReply = scripting.call(QStringLiteral("loadScript"), scriptFile.fileName(), pluginName);
if (!loadReply.isValid() || loadReply.value() < 0) {
err << "failed to load KWin window probe script: "
<< loadReply.error().name() << ": " << loadReply.error().message() << "\n";
bus.unregisterObject(path);
bus.unregisterService(service);
return 1;
}
const int scriptId = loadReply.value();
QDBusInterface script(
QStringLiteral("org.kde.KWin"),
QStringLiteral("/Scripting/Script%1").arg(scriptId),
QStringLiteral("org.kde.kwin.Script"),
bus);
QDBusMessage runReply = script.call(QStringLiteral("run"));
if (runReply.type() == QDBusMessage::ErrorMessage) {
err << "failed to run KWin window probe script: "
<< runReply.errorName() << ": " << runReply.errorMessage() << "\n";
scripting.call(QStringLiteral("unloadScript"), pluginName);
bus.unregisterObject(path);
bus.unregisterService(service);
return 1;
}
QTimer::singleShot(5000, &app, [&receiver]() {
if (!receiver.done) {
receiver.error = QStringLiteral("timed out waiting for KWin window probe results");
receiver.done = true;
QCoreApplication::quit();
}
});
app.exec();
scripting.call(QStringLiteral("unloadScript"), pluginName);
bus.unregisterObject(path);
bus.unregisterService(service);
if (!receiver.error.isEmpty()) {
err << receiver.error << "\n";
return 1;
}
if (receiver.json.isEmpty()) {
err << "KWin window probe returned no data\n";
return 1;
}
out << receiver.json << "\n";
return 0;
}
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QTextStream out(stdout);
QTextStream err(stderr);
const QStringList args = QCoreApplication::arguments();
if (args.size() == 3 && !args.at(1).startsWith(QLatin1String("--"))) {
return capture(QStringLiteral("CaptureScreen"), args.at(1), args.at(2), out, err);
}
if (args.size() == 2 && args.at(1) == QLatin1String("--list-windows")) {
return listWindows(app, out, err);
}
if (args.size() == 4 && args.at(1) == QLatin1String("--capture-screen")) {
return capture(QStringLiteral("CaptureScreen"), args.at(2), args.at(3), out, err);
}
if (args.size() == 4 && args.at(1) == QLatin1String("--capture-window")) {
return capture(QStringLiteral("CaptureWindow"), args.at(2), args.at(3), out, err);
}
err << "usage:\n"
<< " " << args.value(0) << " OUTPUT_NAME OUTPUT_FILE\n"
<< " " << args.value(0) << " --capture-screen OUTPUT_NAME OUTPUT_FILE\n"
<< " " << args.value(0) << " --capture-window INTERNAL_ID OUTPUT_FILE\n"
<< " " << args.value(0) << " --list-windows\n";
return 64;
}
#include "kwin_capture_screen.moc"

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Reforger KWin Capture Helper
Comment=Authorized KWin screenshot helper for the Reforger queue reader
Exec=/usr/local/bin/reforger-kwin-capture
NoDisplay=true
Terminal=false
Categories=Utility;
X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2

518
portal_capture_frame.cpp Normal file
View file

@ -0,0 +1,518 @@
#include <QCoreApplication>
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusError>
#include <QDBusInterface>
#include <QDBusMetaType>
#include <QDBusObjectPath>
#include <QDBusReply>
#include <QDBusUnixFileDescriptor>
#include <QDBusVariant>
#include <QFile>
#include <QImage>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <QTextStream>
#include <QTimer>
#include <QVariantMap>
#include <gst/app/gstappsink.h>
#include <gst/gst.h>
#include <gst/video/video.h>
#include <unistd.h>
static const char *PORTAL_SERVICE = "org.freedesktop.portal.Desktop";
static const char *PORTAL_PATH = "/org/freedesktop/portal/desktop";
static const char *SCREENCAST_IFACE = "org.freedesktop.portal.ScreenCast";
static const uint SOURCE_TYPE_MONITOR = 1;
static const uint SOURCE_TYPE_WINDOW = 2;
static const uint CURSOR_MODE_HIDDEN = 1;
struct PipeWireStream
{
uint nodeId = 0;
QVariantMap properties;
};
Q_DECLARE_METATYPE(PipeWireStream)
Q_DECLARE_METATYPE(QList<PipeWireStream>)
const QDBusArgument &operator>>(const QDBusArgument &argument, PipeWireStream &stream)
{
argument.beginStructure();
argument >> stream.nodeId >> stream.properties;
argument.endStructure();
return argument;
}
QDBusArgument &operator<<(QDBusArgument &argument, const PipeWireStream &stream)
{
argument.beginStructure();
argument << stream.nodeId << stream.properties;
argument.endStructure();
return argument;
}
class RequestWatcher : public QObject
{
Q_OBJECT
public:
uint response = 2;
QVariantMap results;
bool done = false;
QString error;
public slots:
void Response(uint responseCode, const QVariantMap &responseResults)
{
response = responseCode;
results = responseResults;
done = true;
QCoreApplication::quit();
}
};
QDBusConnection sessionBus()
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (bus.isConnected()) {
return bus;
}
QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR");
if (runtimeDir.isEmpty()) {
runtimeDir = QStringLiteral("/run/user/%1").arg(getuid());
}
return QDBusConnection::connectToBus(
QStringLiteral("unix:path=%1/bus").arg(runtimeDir),
QStringLiteral("reforger-portal-session"));
}
QString token()
{
return QStringLiteral("reforger_%1_%2")
.arg(QCoreApplication::applicationPid())
.arg(QString::number(QRandomGenerator::global()->generate(), 16));
}
bool waitForRequest(QCoreApplication &app, QDBusConnection &bus, const QDBusObjectPath &handle, int timeoutMs, RequestWatcher &watcher, QTextStream &err)
{
const bool connected = bus.connect(
QString::fromLatin1(PORTAL_SERVICE),
handle.path(),
QStringLiteral("org.freedesktop.portal.Request"),
QStringLiteral("Response"),
&watcher,
SLOT(Response(uint,QVariantMap)));
if (!connected) {
err << "failed to listen for portal request response on " << handle.path() << ": "
<< bus.lastError().message() << "\n";
return false;
}
QTimer::singleShot(timeoutMs, &app, [&watcher]() {
if (!watcher.done) {
watcher.error = QStringLiteral("timed out waiting for portal response");
watcher.done = true;
QCoreApplication::quit();
}
});
app.exec();
bus.disconnect(
QString::fromLatin1(PORTAL_SERVICE),
handle.path(),
QStringLiteral("org.freedesktop.portal.Request"),
QStringLiteral("Response"),
&watcher,
SLOT(Response(uint,QVariantMap)));
if (!watcher.error.isEmpty()) {
err << watcher.error << "\n";
return false;
}
if (watcher.response == 1) {
err << "portal request was cancelled\n";
return false;
}
if (watcher.response != 0) {
err << "portal request failed with response code " << watcher.response << "\n";
return false;
}
return true;
}
QVariantMap callRequest(
QCoreApplication &app,
QDBusConnection &bus,
QDBusInterface &portal,
const QString &method,
const QList<QVariant> &args,
int timeoutMs,
QTextStream &err,
bool *ok)
{
*ok = false;
QDBusMessage reply = portal.callWithArgumentList(QDBus::Block, method, args);
if (reply.type() == QDBusMessage::ErrorMessage) {
err << method << " failed: " << reply.errorName() << ": " << reply.errorMessage() << "\n";
return {};
}
if (reply.arguments().isEmpty()) {
err << method << " returned no request handle\n";
return {};
}
QDBusObjectPath handle = qdbus_cast<QDBusObjectPath>(reply.arguments().at(0));
if (handle.path().isEmpty()) {
err << method << " returned an invalid request handle\n";
return {};
}
RequestWatcher watcher;
if (!waitForRequest(app, bus, handle, timeoutMs, watcher, err)) {
return {};
}
*ok = true;
return watcher.results;
}
uint portalUIntProperty(QDBusConnection &bus, const QString &name)
{
QDBusInterface props(
QString::fromLatin1(PORTAL_SERVICE),
QString::fromLatin1(PORTAL_PATH),
QStringLiteral("org.freedesktop.DBus.Properties"),
bus);
QDBusReply<QDBusVariant> reply = props.call(QStringLiteral("Get"), QString::fromLatin1(SCREENCAST_IFACE), name);
if (!reply.isValid()) {
return 0;
}
return reply.value().variant().toUInt();
}
QList<PipeWireStream> parseStreams(const QVariant &value)
{
QList<PipeWireStream> streams = qdbus_cast<QList<PipeWireStream>>(value);
if (!streams.isEmpty()) {
return streams;
}
if (value.canConvert<QDBusArgument>()) {
return qdbus_cast<QList<PipeWireStream>>(value.value<QDBusArgument>());
}
return {};
}
QString variantString(const QVariantMap &map, const QString &key)
{
const QVariant value = map.value(key);
if (value.canConvert<QDBusObjectPath>()) {
return value.value<QDBusObjectPath>().path();
}
return value.toString();
}
void closePortalSession(QDBusConnection &bus, const QString &sessionHandle)
{
if (sessionHandle.isEmpty()) {
return;
}
QDBusInterface session(
QString::fromLatin1(PORTAL_SERVICE),
sessionHandle,
QStringLiteral("org.freedesktop.portal.Session"),
bus);
if (session.isValid()) {
session.call(QStringLiteral("Close"));
}
}
int writePipeWireFrame(int fd, uint nodeId, const QString &outputPath, int timeoutMs, QTextStream &err)
{
GstElement *pipeline = gst_pipeline_new("reforger-portal-capture");
GstElement *source = gst_element_factory_make("pipewiresrc", "source");
GstElement *convert = gst_element_factory_make("videoconvert", "convert");
GstElement *filter = gst_element_factory_make("capsfilter", "filter");
GstElement *sink = gst_element_factory_make("appsink", "sink");
if (!pipeline || !source || !convert || !filter || !sink) {
err << "failed to create GStreamer PipeWire capture pipeline\n";
if (pipeline) {
gst_object_unref(pipeline);
}
return 1;
}
const QByteArray node = QByteArray::number(nodeId);
g_object_set(source, "fd", fd, "path", node.constData(), "num-buffers", 1, nullptr);
GstCaps *caps = gst_caps_new_simple("video/x-raw", "format", G_TYPE_STRING, "BGRA", nullptr);
g_object_set(filter, "caps", caps, nullptr);
gst_caps_unref(caps);
g_object_set(sink, "emit-signals", FALSE, "sync", FALSE, "max-buffers", 1, "drop", TRUE, nullptr);
gst_bin_add_many(GST_BIN(pipeline), source, convert, filter, sink, nullptr);
if (!gst_element_link_many(source, convert, filter, sink, nullptr)) {
err << "failed to link GStreamer PipeWire capture pipeline\n";
gst_object_unref(pipeline);
return 1;
}
if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
err << "failed to start GStreamer PipeWire capture pipeline\n";
gst_object_unref(pipeline);
return 1;
}
GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(sink), timeoutMs * GST_MSECOND);
if (!sample) {
GstBus *bus = gst_element_get_bus(pipeline);
GstMessage *message = gst_bus_pop_filtered(bus, static_cast<GstMessageType>(GST_MESSAGE_ERROR | GST_MESSAGE_EOS));
if (message && GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) {
GError *error = nullptr;
gchar *debug = nullptr;
gst_message_parse_error(message, &error, &debug);
err << "GStreamer PipeWire capture failed: " << (error ? error->message : "unknown error") << "\n";
if (debug) {
err << debug << "\n";
}
if (error) {
g_error_free(error);
}
g_free(debug);
} else {
err << "timed out waiting for a PipeWire video frame\n";
}
if (message) {
gst_message_unref(message);
}
gst_object_unref(bus);
gst_element_set_state(pipeline, GST_STATE_NULL);
gst_object_unref(pipeline);
return 1;
}
GstCaps *sampleCaps = gst_sample_get_caps(sample);
GstVideoInfo info;
if (!sampleCaps || !gst_video_info_from_caps(&info, sampleCaps)) {
err << "failed to read video frame format from PipeWire sample\n";
gst_sample_unref(sample);
gst_element_set_state(pipeline, GST_STATE_NULL);
gst_object_unref(pipeline);
return 1;
}
GstBuffer *buffer = gst_sample_get_buffer(sample);
GstMapInfo map;
if (!buffer || !gst_buffer_map(buffer, &map, GST_MAP_READ)) {
err << "failed to map PipeWire video frame\n";
gst_sample_unref(sample);
gst_element_set_state(pipeline, GST_STATE_NULL);
gst_object_unref(pipeline);
return 1;
}
const int width = GST_VIDEO_INFO_WIDTH(&info);
const int height = GST_VIDEO_INFO_HEIGHT(&info);
const int stride = GST_VIDEO_INFO_PLANE_STRIDE(&info, 0);
QImage image(map.data, width, height, stride, QImage::Format_ARGB32);
const bool saved = image.copy().save(outputPath, "PNG");
gst_buffer_unmap(buffer, &map);
gst_sample_unref(sample);
gst_element_set_state(pipeline, GST_STATE_NULL);
gst_object_unref(pipeline);
if (!saved) {
err << "failed to write PNG output: " << outputPath << "\n";
return 1;
}
return 0;
}
int captureWindow(QCoreApplication &app, const QString &outputPath, const QString &restoreToken, bool persist, int timeoutMs, QTextStream &out, QTextStream &err)
{
QDBusConnection bus = sessionBus();
if (!bus.isConnected()) {
err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n";
return 1;
}
const uint sourceTypes = portalUIntProperty(bus, QStringLiteral("AvailableSourceTypes"));
if (!(sourceTypes & SOURCE_TYPE_WINDOW)) {
err << "desktop portal does not advertise window capture support";
if (sourceTypes) {
err << " (AvailableSourceTypes=" << sourceTypes << ")";
}
err << "\n";
return 1;
}
QDBusInterface portal(
QString::fromLatin1(PORTAL_SERVICE),
QString::fromLatin1(PORTAL_PATH),
QString::fromLatin1(SCREENCAST_IFACE),
bus);
if (!portal.isValid()) {
err << "failed to connect to desktop ScreenCast portal: " << portal.lastError().message() << "\n";
return 1;
}
QVariantMap createOptions;
createOptions.insert(QStringLiteral("handle_token"), token());
createOptions.insert(QStringLiteral("session_handle_token"), token());
bool ok = false;
QVariantMap createResults = callRequest(app, bus, portal, QStringLiteral("CreateSession"), {createOptions}, timeoutMs, err, &ok);
if (!ok) {
return 1;
}
const QString sessionHandle = variantString(createResults, QStringLiteral("session_handle"));
if (sessionHandle.isEmpty()) {
err << "portal CreateSession did not return a session handle\n";
return 1;
}
QVariantMap selectOptions;
selectOptions.insert(QStringLiteral("handle_token"), token());
selectOptions.insert(QStringLiteral("types"), SOURCE_TYPE_WINDOW);
selectOptions.insert(QStringLiteral("multiple"), false);
selectOptions.insert(QStringLiteral("cursor_mode"), CURSOR_MODE_HIDDEN);
if (!restoreToken.isEmpty()) {
selectOptions.insert(QStringLiteral("restore_token"), restoreToken);
}
if (persist) {
selectOptions.insert(QStringLiteral("persist_mode"), 2u);
}
callRequest(app, bus, portal, QStringLiteral("SelectSources"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), selectOptions}, timeoutMs, err, &ok);
if (!ok) {
closePortalSession(bus, sessionHandle);
return 1;
}
QVariantMap startOptions;
startOptions.insert(QStringLiteral("handle_token"), token());
QVariantMap startResults = callRequest(app, bus, portal, QStringLiteral("Start"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), QString(), startOptions}, timeoutMs, err, &ok);
if (!ok) {
closePortalSession(bus, sessionHandle);
return 1;
}
const QList<PipeWireStream> streams = parseStreams(startResults.value(QStringLiteral("streams")));
if (streams.isEmpty()) {
err << "portal Start did not return any PipeWire streams\n";
closePortalSession(bus, sessionHandle);
return 1;
}
const PipeWireStream stream = streams.first();
if (stream.nodeId == 0) {
err << "portal returned an invalid PipeWire node id\n";
closePortalSession(bus, sessionHandle);
return 1;
}
QDBusReply<QDBusUnixFileDescriptor> fdReply = portal.call(
QStringLiteral("OpenPipeWireRemote"),
QVariant::fromValue(QDBusObjectPath(sessionHandle)),
QVariantMap());
if (!fdReply.isValid()) {
err << "OpenPipeWireRemote failed: " << fdReply.error().name() << ": " << fdReply.error().message() << "\n";
closePortalSession(bus, sessionHandle);
return 1;
}
const QDBusUnixFileDescriptor remoteFd = fdReply.value();
if (!remoteFd.isValid()) {
err << "OpenPipeWireRemote returned an invalid file descriptor\n";
closePortalSession(bus, sessionHandle);
return 1;
}
const int status = writePipeWireFrame(remoteFd.fileDescriptor(), stream.nodeId, outputPath, timeoutMs, err);
closePortalSession(bus, sessionHandle);
if (status != 0) {
return status;
}
QJsonObject info;
info.insert(QStringLiteral("node_id"), static_cast<int>(stream.nodeId));
info.insert(QStringLiteral("session_handle"), sessionHandle);
const QString newRestoreToken = variantString(startResults, QStringLiteral("restore_token"));
if (!newRestoreToken.isEmpty()) {
info.insert(QStringLiteral("restore_token"), newRestoreToken);
}
QJsonArray streamArray;
for (const PipeWireStream &pipeWireStream : streams) {
QJsonObject streamObject;
streamObject.insert(QStringLiteral("node_id"), static_cast<int>(pipeWireStream.nodeId));
QJsonObject properties;
for (auto it = pipeWireStream.properties.constBegin(); it != pipeWireStream.properties.constEnd(); ++it) {
properties.insert(it.key(), QJsonValue::fromVariant(it.value()));
}
streamObject.insert(QStringLiteral("properties"), properties);
streamArray.append(streamObject);
}
info.insert(QStringLiteral("streams"), streamArray);
out << QJsonDocument(info).toJson(QJsonDocument::Compact) << "\n";
return 0;
}
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
gst_init(&argc, &argv);
qDBusRegisterMetaType<PipeWireStream>();
qDBusRegisterMetaType<QList<PipeWireStream>>();
QTextStream out(stdout);
QTextStream err(stderr);
const QStringList args = QCoreApplication::arguments();
QString outputPath;
QString restoreToken;
bool persist = false;
int timeoutMs = 60000;
for (int i = 1; i < args.size(); ++i) {
const QString arg = args.at(i);
if (arg == QLatin1String("--window") && i + 1 < args.size()) {
outputPath = args.at(++i);
} else if (arg == QLatin1String("--restore-token") && i + 1 < args.size()) {
restoreToken = args.at(++i);
} else if (arg == QLatin1String("--persist")) {
persist = true;
} else if (arg == QLatin1String("--timeout") && i + 1 < args.size()) {
bool ok = false;
const double seconds = args.at(++i).toDouble(&ok);
if (!ok || seconds <= 0) {
err << "invalid --timeout value\n";
return 64;
}
timeoutMs = qRound(seconds * 1000.0);
} else {
err << "unknown or incomplete argument: " << arg << "\n";
return 64;
}
}
if (outputPath.isEmpty()) {
err << "usage: " << args.value(0)
<< " --window OUTPUT.png [--restore-token TOKEN] [--persist] [--timeout SECONDS]\n";
return 64;
}
return captureWindow(app, outputPath, restoreToken, persist, timeoutMs, out, err);
}
#include "portal_capture_frame.moc"

File diff suppressed because it is too large Load diff