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
|
.venv
|
||||||
|
|
||||||
kwin_capture_screen
|
kwin_capture_screen
|
||||||
|
portal_capture_frame
|
||||||
|
*.moc
|
||||||
|
|
|
||||||
34
Makefile
34
Makefile
|
|
@ -1,15 +1,39 @@
|
||||||
CXX ?= g++
|
CXX ?= g++
|
||||||
PKG_CONFIG ?= pkg-config
|
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_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus)
|
||||||
QT_LIBS := $(shell $(PKG_CONFIG) --libs 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
|
kwin_capture_screen: kwin_capture_screen.cpp kwin_capture_screen.moc
|
||||||
$(CXX) -std=c++17 -O2 -Wall -Wextra $(QT_CFLAGS) $< -o $@ $(QT_LIBS)
|
$(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:
|
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
|
# 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
|
```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:
|
You also need a portal backend for your desktop:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```text
|
```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
|
```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
|
## Use
|
||||||
summary: 14 passed, 0 failed
|
|
||||||
```
|
|
||||||
|
|
||||||
The default crop is derived from this 2560x1440 reference:
|
Preferred command:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```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
|
```text
|
||||||
datasets/scootz-dataset/1920x1080.png crop=788,465,75,45
|
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
|
datasets/scootz-dataset/2160x1440.png 24
|
||||||
```
|
```
|
||||||
|
|
||||||
Measured digit box positions:
|
## Notes
|
||||||
|
|
||||||
|
Default crop reference:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1440p digit bbox abs=(1080,636)-(1118,666)
|
reference-size: 2560x1440
|
||||||
1440p digit bbox rel=(0.421875,0.441667)-(0.436719,0.462500)
|
reference-crop: 1050,620,100,60
|
||||||
|
scale-mode: width
|
||||||
1080p digit bbox abs=(810,477)-(838,500)
|
|
||||||
1080p digit bbox rel=(0.421875,0.441667)-(0.436458,0.462963)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
KWin mode is the best KDE automation path, but it has the extra authorization install step.
|
||||||
|
|
||||||
```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`.
|
|
||||||
|
|
|
||||||
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 <QDBusInterface>
|
||||||
#include <QDBusReply>
|
#include <QDBusReply>
|
||||||
#include <QDBusUnixFileDescriptor>
|
#include <QDBusUnixFileDescriptor>
|
||||||
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QTemporaryFile>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
|
#include <QTimer>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
#include <unistd.h>
|
||||||
{
|
|
||||||
QCoreApplication app(argc, argv);
|
|
||||||
QTextStream err(stderr);
|
|
||||||
|
|
||||||
if (argc != 3) {
|
class WindowReceiver : public QObject
|
||||||
err << "usage: " << argv[0] << " OUTPUT_NAME OUTPUT_FILE\n";
|
{
|
||||||
return 64;
|
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]);
|
void Error(const QString &message)
|
||||||
const QString outputPath = QString::fromLocal8Bit(argv[2]);
|
{
|
||||||
|
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);
|
QFile file(outputPath);
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
err << "failed to open output file: " << file.errorString() << "\n";
|
err << "failed to open output file: " << file.errorString() << "\n";
|
||||||
return 1;
|
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(
|
QDBusInterface iface(
|
||||||
QStringLiteral("org.kde.KWin"),
|
QStringLiteral("org.kde.KWin"),
|
||||||
QStringLiteral("/org/kde/KWin/ScreenShot2"),
|
QStringLiteral("/org/kde/KWin/ScreenShot2"),
|
||||||
QStringLiteral("org.kde.KWin.ScreenShot2"),
|
QStringLiteral("org.kde.KWin.ScreenShot2"),
|
||||||
QDBusConnection::sessionBus());
|
bus);
|
||||||
|
|
||||||
if (!iface.isValid()) {
|
if (!iface.isValid()) {
|
||||||
err << "failed to connect to KWin ScreenShot2: "
|
err << "failed to connect to KWin ScreenShot2: "
|
||||||
|
|
@ -41,19 +97,238 @@ int main(int argc, char *argv[])
|
||||||
|
|
||||||
QVariantMap options;
|
QVariantMap options;
|
||||||
QDBusUnixFileDescriptor fd(file.handle());
|
QDBusUnixFileDescriptor fd(file.handle());
|
||||||
QDBusReply<QVariantMap> reply = iface.call(
|
QDBusReply<QVariantMap> reply = iface.call(method, target, options, QVariant::fromValue(fd));
|
||||||
QStringLiteral("CaptureScreen"),
|
|
||||||
outputName,
|
|
||||||
options,
|
|
||||||
QVariant::fromValue(fd));
|
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (!reply.isValid()) {
|
if (!reply.isValid()) {
|
||||||
err << "CaptureScreen failed for " << outputName << ": "
|
err << method << " failed for " << target << ": "
|
||||||
<< reply.error().name() << ": " << reply.error().message() << "\n";
|
<< reply.error().name() << ": " << reply.error().message() << "\n";
|
||||||
return 1;
|
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;
|
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