gamecenter: refactor ProcessScanner for cancellable single-pass scans

Add ProcessScanner::scan(ScanOptions) for combined env/cmdline/exe matching, extend Match with match-source flags, and make pollUntilFound() cancellation-aware. Update monitored launch matchers to use the new API.
This commit is contained in:
Marco Allegretti 2026-02-14 17:38:28 +01:00
parent 7200ad179c
commit f03eb95b52
3 changed files with 225 additions and 122 deletions

View file

@ -1402,7 +1402,7 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
// Determine bootstrap command and scanner matcher
QString bootstrapProgram;
QStringList bootstrapArgs;
std::function<QList<ProcessScanner::Match>()> matcher;
ProcessScanner::Matcher matcher;
if (provider == QLatin1String("steam")) {
const QString appId = extractSteamAppId(command);
@ -1414,9 +1414,13 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
bootstrapProgram = QStringLiteral("steam");
bootstrapArgs = {QStringLiteral("-silent"), QStringLiteral("-applaunch"), appId};
matcher = [appId]() -> QList<ProcessScanner::Match> {
// Primary: look for SteamAppId=<appId> in environ
QList<ProcessScanner::Match> results = ProcessScanner::findByAnyEnvironment({QStringLiteral("SteamAppId"), QStringLiteral("SteamGameId")}, appId);
matcher = [appId](const std::atomic_bool &cancelled) -> QList<ProcessScanner::Match> {
ProcessScanner::ScanOptions opts;
opts.envKeys = {QStringLiteral("SteamAppId"), QStringLiteral("SteamGameId")};
opts.envValue = appId;
opts.preferEnvironmentMatches = true;
QList<ProcessScanner::Match> results = ProcessScanner::scan(opts, cancelled);
// Filter out the steam client itself
QList<ProcessScanner::Match> filtered;
for (const auto &m : std::as_const(results)) {
@ -1438,11 +1442,25 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
bootstrapArgs = {QStringLiteral("lutris:rungameid/%1").arg(lutrisId)};
// For Lutris, look for child processes with the game slug or lutris game env
matcher = [lutrisId]() -> QList<ProcessScanner::Match> {
QList<ProcessScanner::Match> results = ProcessScanner::findByEnvironment(QStringLiteral("LUTRIS_GAME_SLUG"), lutrisId);
if (results.isEmpty()) {
results = ProcessScanner::findByCmdline(lutrisId);
// Filter out lutris itself
matcher = [lutrisId](const std::atomic_bool &cancelled) -> QList<ProcessScanner::Match> {
ProcessScanner::ScanOptions opts;
opts.envKeys = {QStringLiteral("LUTRIS_GAME_SLUG")};
opts.envValue = lutrisId;
opts.cmdlineSubstring = lutrisId;
opts.preferEnvironmentMatches = true;
QList<ProcessScanner::Match> results = ProcessScanner::scan(opts, cancelled);
bool anyEnvMatch = false;
for (const auto &m : std::as_const(results)) {
if (m.envMatched) {
anyEnvMatch = true;
break;
}
}
if (!anyEnvMatch) {
// Filter out lutris itself (only for cmdline fallback)
QList<ProcessScanner::Match> filtered;
for (const auto &m : std::as_const(results)) {
if (!m.exe.endsWith(QLatin1String("/lutris")) && !m.exe.endsWith(QLatin1String("/python3"))) {
@ -1453,6 +1471,7 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
results = filtered;
}
}
return results;
};
}

View file

@ -6,8 +6,19 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QtConcurrentRun>
#include <utility>
Q_LOGGING_CATEGORY(lcProcessScanner, "org.kde.alakarte.processscanner")
static QString readExeLink(const QString &pidDir);
static QByteArray readCmdline(const QString &pidDir);
static QByteArray readEnviron(const QString &pidDir);
static bool containsNullSeparatedEntry(const QByteArray &blob, const QByteArray &needle);
static QList<uint> listPids();
ProcessScanner::ProcessScanner(QObject *parent)
: QObject(parent)
{
@ -16,7 +27,11 @@ ProcessScanner::ProcessScanner(QObject *parent)
connect(&m_deadline, &QTimer::timeout, this, [this]() {
m_timer.stop();
m_matcher = nullptr;
if (m_cancelToken) {
m_cancelToken->store(true);
}
m_cancelToken.reset();
m_matcher = {};
++m_generation;
m_scanInFlight = false;
Q_EMIT timedOut();
@ -42,7 +57,8 @@ void ProcessScanner::startScan()
m_scanInFlight = true;
m_scanGeneration = gen;
const std::function<QList<Match>()> matcher = m_matcher;
const Matcher matcher = m_matcher;
const std::shared_ptr<std::atomic_bool> cancelToken = m_cancelToken;
auto *watcher = new QFutureWatcher<QList<Match>>(this);
connect(watcher, &QFutureWatcher<QList<Match>>::finished, this, [this, watcher, gen]() {
@ -60,19 +76,130 @@ void ProcessScanner::startScan()
if (!results.isEmpty()) {
m_timer.stop();
m_deadline.stop();
m_matcher = nullptr;
if (m_cancelToken) {
m_cancelToken->store(true);
}
m_cancelToken.reset();
m_matcher = {};
Q_EMIT found(results);
}
});
watcher->setFuture(QtConcurrent::run([matcher]() {
watcher->setFuture(QtConcurrent::run([matcher, cancelToken]() {
static const std::atomic_bool neverCancelled{false};
const std::atomic_bool &cancelled = cancelToken ? *cancelToken : neverCancelled;
if (!matcher) {
return QList<Match>{};
}
return matcher();
return matcher(cancelled);
}));
}
QList<ProcessScanner::Match> ProcessScanner::scan(const ScanOptions &options)
{
static const std::atomic_bool neverCancelled{false};
return scan(options, neverCancelled);
}
QList<ProcessScanner::Match> ProcessScanner::scan(const ScanOptions &options, const std::atomic_bool &cancelled)
{
QList<Match> matches;
QList<QByteArray> envNeedles;
if (!options.envKeys.isEmpty() && !options.envValue.isEmpty()) {
envNeedles.reserve(options.envKeys.size());
for (const QString &key : options.envKeys) {
if (!key.isEmpty()) {
envNeedles.push_back((key + QLatin1Char('=') + options.envValue).toUtf8());
}
}
}
const QByteArray cmdNeedle = options.cmdlineSubstring.isEmpty() ? QByteArray{} : options.cmdlineSubstring.toUtf8();
const bool checkExePrefix = !options.exePathPrefix.isEmpty();
const QList<uint> pids = listPids();
for (uint pid : pids) {
if (cancelled.load()) {
break;
}
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
bool envMatched = false;
if (!envNeedles.isEmpty()) {
const QByteArray env = readEnviron(pidDir);
if (!env.isEmpty()) {
for (const QByteArray &needle : envNeedles) {
if (containsNullSeparatedEntry(env, needle)) {
envMatched = true;
break;
}
}
}
}
bool cmdlineMatched = false;
QByteArray cmd;
if (!cmdNeedle.isEmpty()) {
cmd = readCmdline(pidDir);
cmdlineMatched = !cmd.isEmpty() && cmd.contains(cmdNeedle);
}
bool exeMatched = false;
QString exe;
if (checkExePrefix) {
exe = readExeLink(pidDir);
exeMatched = !exe.isEmpty() && exe.startsWith(options.exePathPrefix);
}
if (!envMatched && !cmdlineMatched && !exeMatched) {
continue;
}
Match m;
m.pid = pid;
m.envMatched = envMatched;
m.cmdlineMatched = cmdlineMatched;
m.exeMatched = exeMatched;
if (exe.isEmpty()) {
exe = readExeLink(pidDir);
}
m.exe = exe;
if (cmd.isEmpty()) {
cmd = readCmdline(pidDir);
}
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
if (options.preferEnvironmentMatches) {
bool anyEnvMatch = false;
for (const Match &m : std::as_const(matches)) {
if (m.envMatched) {
anyEnvMatch = true;
break;
}
}
if (anyEnvMatch) {
QList<Match> filtered;
filtered.reserve(matches.size());
for (const Match &m : std::as_const(matches)) {
if (m.envMatched) {
filtered.push_back(m);
}
}
return filtered;
}
}
return matches;
}
static QByteArray readProcFile(const QString &path, qint64 maxSize = 65536)
{
QFile f(path);
@ -136,141 +263,75 @@ static QList<uint> listPids()
QList<ProcessScanner::Match> ProcessScanner::findByEnvironment(const QString &key, const QString &value)
{
QList<Match> matches;
const QByteArray needle = (key + QLatin1Char('=') + value).toUtf8();
const QList<uint> pids = listPids();
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QByteArray env = readEnviron(pidDir);
if (env.isEmpty()) {
continue;
}
if (containsNullSeparatedEntry(env, needle)) {
Match m;
m.pid = pid;
m.exe = readExeLink(pidDir);
const QByteArray cmd = readCmdline(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
}
return matches;
ScanOptions opts;
opts.envKeys = {key};
opts.envValue = value;
return scan(opts);
}
QList<ProcessScanner::Match> ProcessScanner::findByAnyEnvironment(const QStringList &keys, const QString &value)
{
QList<Match> matches;
if (keys.isEmpty()) {
return matches;
}
QList<QByteArray> needles;
needles.reserve(keys.size());
for (const QString &key : keys) {
if (!key.isEmpty()) {
needles.push_back((key + QLatin1Char('=') + value).toUtf8());
}
}
if (needles.isEmpty()) {
return matches;
}
const QList<uint> pids = listPids();
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QByteArray env = readEnviron(pidDir);
if (env.isEmpty()) {
continue;
}
bool found = false;
for (const QByteArray &needle : needles) {
if (containsNullSeparatedEntry(env, needle)) {
found = true;
break;
}
}
if (!found) {
continue;
}
Match m;
m.pid = pid;
m.exe = readExeLink(pidDir);
const QByteArray cmd = readCmdline(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
return matches;
ScanOptions opts;
opts.envKeys = keys;
opts.envValue = value;
return scan(opts);
}
QList<ProcessScanner::Match> ProcessScanner::findByCmdline(const QString &substring)
{
QList<Match> matches;
const QByteArray needle = substring.toUtf8();
const QList<uint> pids = listPids();
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QByteArray cmd = readCmdline(pidDir);
if (cmd.isEmpty()) {
continue;
}
// cmdline has null-separated args; search the whole blob
if (cmd.contains(needle)) {
Match m;
m.pid = pid;
m.exe = readExeLink(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
}
return matches;
ScanOptions opts;
opts.cmdlineSubstring = substring;
return scan(opts);
}
QList<ProcessScanner::Match> ProcessScanner::findByExePath(const QString &dirPrefix)
{
QList<Match> matches;
const QList<uint> pids = listPids();
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QString exe = readExeLink(pidDir);
if (exe.isEmpty()) {
continue;
}
if (exe.startsWith(dirPrefix)) {
Match m;
m.pid = pid;
m.exe = exe;
const QByteArray cmd = readCmdline(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
}
return matches;
ScanOptions opts;
opts.exePathPrefix = dirPrefix;
return scan(opts);
}
void ProcessScanner::pollUntilFound(std::function<QList<Match>()> matcher, int intervalMs, int timeoutMs)
void ProcessScanner::pollUntilFound(Matcher matcher, int intervalMs, int timeoutMs)
{
cancel();
m_cancelToken = std::make_shared<std::atomic_bool>(false);
m_matcher = std::move(matcher);
if (!m_matcher) {
return;
}
m_deadline.start(timeoutMs);
m_timer.start(intervalMs);
startScan();
}
void ProcessScanner::pollUntilFound(std::function<QList<Match>()> matcher, int intervalMs, int timeoutMs)
{
pollUntilFound(
[matcher = std::move(matcher)](const std::atomic_bool &cancelled) {
if (cancelled.load()) {
return QList<Match>{};
}
if (!matcher) {
return QList<Match>{};
}
return matcher();
},
intervalMs,
timeoutMs);
}
void ProcessScanner::cancel()
{
m_timer.stop();
m_deadline.stop();
m_matcher = nullptr;
if (m_cancelToken) {
m_cancelToken->store(true);
}
m_cancelToken.reset();
m_matcher = {};
++m_generation;
m_scanInFlight = false;
}

View file

@ -5,11 +5,16 @@
#include <QFutureWatcher>
#include <QList>
#include <QLoggingCategory>
#include <QObject>
#include <QString>
#include <QTimer>
#include <QtGlobal>
#include <atomic>
#include <functional>
#include <memory>
Q_DECLARE_LOGGING_CATEGORY(lcProcessScanner)
class ProcessScanner : public QObject
{
@ -18,12 +23,28 @@ class ProcessScanner : public QObject
public:
explicit ProcessScanner(QObject *parent = nullptr);
struct ScanOptions {
QStringList envKeys;
QString envValue;
QString cmdlineSubstring;
QString exePathPrefix;
bool preferEnvironmentMatches = false;
};
struct Match {
uint pid = 0;
QString exe;
QString cmdline;
bool envMatched = false;
bool cmdlineMatched = false;
bool exeMatched = false;
};
using Matcher = std::function<QList<Match>(const std::atomic_bool &cancelled)>;
static QList<Match> scan(const ScanOptions &options);
static QList<Match> scan(const ScanOptions &options, const std::atomic_bool &cancelled);
// Find PIDs whose /proc/<pid>/environ contains key=value
static QList<Match> findByEnvironment(const QString &key, const QString &value);
@ -38,6 +59,7 @@ public:
// Async poll: calls matcher repeatedly until it returns non-empty or timeout.
// Emits found() with matching PIDs, or timedOut() on failure.
void pollUntilFound(Matcher matcher, int intervalMs = 500, int timeoutMs = 15000);
void pollUntilFound(std::function<QList<Match>()> matcher, int intervalMs = 500, int timeoutMs = 15000);
void cancel();
@ -51,7 +73,8 @@ private:
QTimer m_timer;
QTimer m_deadline;
std::function<QList<Match>()> m_matcher;
Matcher m_matcher;
std::shared_ptr<std::atomic_bool> m_cancelToken;
quint64 m_generation = 0;
bool m_scanInFlight = false;
quint64 m_scanGeneration = 0;