Add portal window capture backend
This commit is contained in:
parent
ee0cb2bc8a
commit
3216f4a7a2
9 changed files with 1924 additions and 193 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,3 +10,5 @@ wheels/
|
|||
.venv
|
||||
|
||||
kwin_capture_screen
|
||||
portal_capture_frame
|
||||
*.moc
|
||||
|
|
|
|||
34
Makefile
34
Makefile
|
|
@ -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
277
README.md
|
|
@ -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.
|
||||
|
|
|
|||
BIN
datasets/scootz-dataset/1920x1080.png
Normal file
BIN
datasets/scootz-dataset/1920x1080.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
datasets/scootz-dataset/2160x1440.png
Normal file
BIN
datasets/scootz-dataset/2160x1440.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -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"
|
||||
|
|
|
|||
9
org.codex.reforger-kwin-capture.desktop
Normal file
9
org.codex.reforger-kwin-capture.desktop
Normal 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
518
portal_capture_frame.cpp
Normal 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
Loading…
Add table
Reference in a new issue