Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
run: ./scripts/install-build-deps.sh tar patchelf squashfs-tools

- name: Configure CMake
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DCOUCHPLAY_BETA=ON

- name: Build
run: cmake --build build --parallel 2
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
fi

- name: Configure CMake
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DCOUCHPLAY_BETA=OFF

- name: Build
run: cmake --build build --parallel 2
Expand Down
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ find_package(PolkitQt6-1 REQUIRED)
# Find QML modules at runtime
ecm_find_qmlmodule(org.kde.kirigami REQUIRED)

# Build for the beta release channel: enables verbose + rotating-file logging.
# Declared before add_subdirectory(src) because that is where it is consumed.
# Prod (default) builds stay quiet (warnings only). QT_LOGGING_RULES still overrides.
option(COUCHPLAY_BETA "Build a beta release with verbose/file logging" OFF)

# Add subdirectories
add_subdirectory(src)
add_subdirectory(helper)
Expand Down
4 changes: 4 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ set_target_properties(couchplay_core PROPERTIES
# Create the executable
add_executable(couchplay)

if(COUCHPLAY_BETA)
target_compile_definitions(couchplay PRIVATE COUCHPLAY_BETA)
endif()

# Create QML module
ecm_add_qml_module(couchplay
URI io.github.hikaps.couchplay
Expand Down
74 changes: 72 additions & 2 deletions src/core/Logging.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,82 @@

#include "Logging.h"

// Define logging categories
// By default, debug messages are disabled; enable via QT_LOGGING_RULES
#include <QDebug>
#include <QDir>
#include <QFileInfo>

Q_LOGGING_CATEGORY(couchplayCore, "couchplay.core", QtWarningMsg)
Q_LOGGING_CATEGORY(couchplaySteam, "couchplay.steam", QtWarningMsg)
Q_LOGGING_CATEGORY(couchplayHelper, "couchplay.helper", QtWarningMsg)
Q_LOGGING_CATEGORY(couchplayGamescope, "couchplay.gamescope", QtWarningMsg)
Q_LOGGING_CATEGORY(couchplayDevices, "couchplay.devices", QtWarningMsg)
Q_LOGGING_CATEGORY(couchplaySharing, "couchplay.sharing", QtWarningMsg)

RotatingFileLogger::RotatingFileLogger(QString filePath, qint64 maxSizeBytes, int maxBackups)
: m_filePath(std::move(filePath))
, m_maxSizeBytes(maxSizeBytes)
, m_maxBackups(maxBackups)
{
}

RotatingFileLogger::~RotatingFileLogger()
{
QMutexLocker lock(&m_mutex);
m_file.close();
}

bool RotatingFileLogger::open()
{
QMutexLocker lock(&m_mutex);
const QString parentDir = QFileInfo(m_filePath).absolutePath();
if (!QDir().mkpath(parentDir)) {
return false;
}
m_file.setFileName(m_filePath);
if (!m_file.open(QIODevice::Append | QIODevice::Text)) {
return false;
}
m_written = m_file.size(); // account for an existing log file's size
return true;
}

void RotatingFileLogger::write(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
QMutexLocker lock(&m_mutex);
if (!m_file.isOpen()) {
return;
}
const QString line = qFormatLogMessage(type, context, msg) + QLatin1Char('\n');
const qint64 n = m_file.write(line.toUtf8());
if (n > 0) {
m_written += n;
}
if (m_written >= m_maxSizeBytes) {
m_file.flush();
rotate();
}
}

void RotatingFileLogger::rotate()
{
// Held under m_mutex. Vacate the oldest slot first, then shift .1->.2->...->maxBackups,
// and move the active file into the vacated .1 slot. Vacating before filling keeps
// QFile::rename reliable (it never overwrites) and the cap correct even for maxBackups==1
// (where the shift loop is a no-op and the single .1 backup is simply replaced each rotation).
m_file.close();
QFile::remove(m_filePath + QLatin1Char('.') + QString::number(m_maxBackups)); // drop the oldest slot
for (int i = m_maxBackups; i > 1; --i) {
const QString older = m_filePath + QLatin1Char('.') + QString::number(i);
const QString newer = m_filePath + QLatin1Char('.') + QString::number(i - 1);
QFile::rename(newer, older);
}
// Active file -> .1, then reopen fresh.
QFile::rename(m_filePath, m_filePath + QLatin1String(".1"));
m_file.setFileName(m_filePath);
m_written = 0;
if (!m_file.open(QIODevice::Append | QIODevice::Text)) {
// Reopen failed (e.g. external deletion race); leave closed — subsequent writes are safe no-ops.
return;
}
}

32 changes: 32 additions & 0 deletions src/core/Logging.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

#pragma once

#include <QFile>
#include <QMessageLogContext>
#include <QMutex>
#include <QLoggingCategory>

/**
Expand Down Expand Up @@ -37,3 +40,32 @@ Q_DECLARE_LOGGING_CATEGORY(couchplayDevices)

// Directory sharing
Q_DECLARE_LOGGING_CATEGORY(couchplaySharing)

/**
* RotatingFileLogger — dependency-free rotating file sink for beta logging.
*
* Writes formatted log lines to a file; when the file exceeds maxSizeBytes it
* is rotated to .1, .1 to .2, ..., up to maxBackups files (oldest beyond the cap
* is dropped). Thread-safe via an internal mutex. Best-effort: write/rotation
* failures are silently ignored so logging never crashes the app.
*
* Precondition: maxBackups >= 1. Callers passing < 1 are unsupported.
*/
class RotatingFileLogger {
public:
// maxSizeBytes default 5 MiB; maxBackups default 3 (couchplay.log + .1/.2/.3 = 20 MiB ceiling).
explicit RotatingFileLogger(QString filePath, qint64 maxSizeBytes = 5 * 1024 * 1024, int maxBackups = 3);
~RotatingFileLogger();
bool open(); // mkpath parent dir, open file append; return false on any failure (app continues, no file logging)
void write(QtMsgType type, const QMessageLogContext &context, const QString &msg); // thread-safe; rotates when threshold exceeded
const QString &filePath() const { return m_filePath; }

private:
void rotate(); // called under m_mutex
QString m_filePath;
qint64 m_maxSizeBytes;
int m_maxBackups;
QFile m_file;
qint64 m_written = 0; // logical bytes written to current file (drives rotation; survives buffering)
QMutex m_mutex;
};
47 changes: 24 additions & 23 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,36 @@
// SPDX-FileCopyrightText: 2024 hikaps

#include <QApplication>
#include <QDebug>
#include <QDir>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QStandardPaths>
#include <QtQml>

#include <KIconTheme>
#include <KLocalizedContext>
#include <KLocalizedString>

#include <memory>

#include "couchplay-version.h"

#include "core/AudioManager.h"
#include "core/DeviceManager.h"
#include "core/GamescopeInstance.h"
#include "core/Logging.h"
#include "core/MonitorManager.h"
#include "core/PresetManager.h"
#include "core/SessionManager.h"
#include "core/SessionRunner.h"
#include "core/UserManager.h"
#include "dbus/CouchPlayHelperClient.h"

#include <QFile>
#include <QTextStream>
#include <QDateTime>
#include <QStandardPaths>

// Custom message handler to filter noisy Qt warnings
static QtMessageHandler s_originalHandler = nullptr;
static std::unique_ptr<RotatingFileLogger> s_fileLogger;

void couchplayMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
Expand All @@ -39,25 +41,10 @@ void couchplayMessageHandler(QtMsgType type, const QMessageLogContext &context,
return;
}

// Optional file logging, opt-in via COUCHPLAY_LOG (debug aid; off by default).
static const bool s_logToFile = !qgetenv("COUCHPLAY_LOG").isEmpty();
if (s_logToFile) {
QFile logFile(QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation)
+ QStringLiteral("/couchplay/couchplay.log"));
if (logFile.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
QTextStream stream(&logFile);
QString typeStr = QStringLiteral("DEBUG");
switch (type) {
case QtDebugMsg: typeStr = QStringLiteral("DEBUG"); break;
case QtInfoMsg: typeStr = QStringLiteral("INFO"); break;
case QtWarningMsg: typeStr = QStringLiteral("WARN"); break;
case QtCriticalMsg: typeStr = QStringLiteral("CRIT"); break;
case QtFatalMsg: typeStr = QStringLiteral("FATAL"); break;
}
stream << "[" << QDateTime::currentDateTime().toString(Qt::ISODate) << "] [" << typeStr << "] " << msg << "\n";
}
// Beta channel: mirror every message to the rotating file sink (no-op when null).
if (s_fileLogger) {
s_fileLogger->write(type, context, msg);
}

if (s_originalHandler) {
s_originalHandler(type, context, msg);
}
Expand All @@ -82,6 +69,20 @@ int main(int argc, char *argv[])
QApplication::setDesktopFileName(QStringLiteral("io.github.hikaps.couchplay"));
QApplication::setWindowIcon(QIcon::fromTheme(QStringLiteral("io.github.hikaps.couchplay")));

#ifdef COUCHPLAY_BETA
// Beta channel: enable all couchplay.* debug categories and mirror to a rotating file.
// QT_LOGGING_RULES still overrides this (env var > setFilterRules).
QLoggingCategory::setFilterRules(QStringLiteral("couchplay.*=true"));
const QString logDir = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/logs");
QDir().mkpath(logDir);
s_fileLogger = std::make_unique<RotatingFileLogger>(logDir + QStringLiteral("/couchplay.log"));
if (!s_fileLogger->open()) {
s_fileLogger.reset(); // file logging unavailable; console (verbose) still works
}
#else
// Prod channel: keep category defaults (warnings only). QT_LOGGING_RULES still works.
#endif

// Set Qt Quick style
QApplication::setStyle(QStringLiteral("breeze"));
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ add_couchplay_test(test_streaming_session)
add_couchplay_test(test_streammanager)
add_couchplay_test(test_sunshine_config)
add_couchplay_test(test_usermanager)
add_couchplay_test(test_logging)

# Add helper tests
add_helper_test(test_couchplayhelper)
114 changes: 114 additions & 0 deletions tests/test_logging.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 CouchPlay Contributors

#include <QDebug>
#include <QObject>
#include <QTest>

#include <QFile>
#include <QFileInfo>
#include <QTemporaryDir>

#include "../src/core/Logging.h"

class TestLogging : public QObject
{
Q_OBJECT

private Q_SLOTS:
void testRotation();
void testOpenFailureNoCrash();
void testAppendToExisting();
void testMaxBackupsOne();
};

void TestLogging::testRotation()
{
// Small maxSize forces rotation quickly; "sub/" verifies mkpath of the parent dir.
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString path = dir.path() + QStringLiteral("/sub/couchplay.log");

RotatingFileLogger logger(path, 200, 3);
QVERIFY(logger.open());

// Write well past the 200-byte threshold so multiple rotations occur.
for (int i = 0; i < 50; ++i) {
logger.write(QtInfoMsg, {}, QStringLiteral("line %1 padding to fill the 200-byte bucket").arg(i));
}

// Active + the full backup cascade (.1/.2/.3) exist; the cap holds (no .4).
QVERIFY(QFile::exists(path));
QVERIFY(QFile::exists(path + QStringLiteral(".1")));
QVERIFY(QFile::exists(path + QStringLiteral(".2")));
QVERIFY(QFile::exists(path + QStringLiteral(".3")));
QVERIFY(!QFile::exists(path + QStringLiteral(".4")));
}

void TestLogging::testOpenFailureNoCrash()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());

// Create a regular file that will be treated as the parent directory.
QFile blocker(dir.filePath(QStringLiteral("blocker")));
QVERIFY(blocker.open(QIODevice::WriteOnly));
blocker.close();

// Parent path is a file, so mkpath fails and open() returns false.
RotatingFileLogger logger(dir.filePath(QStringLiteral("blocker")) + QStringLiteral("/couchplay.log"));
QVERIFY(!logger.open());

// Writing to a logger that failed to open must be a safe no-op: no crash, no file created.
logger.write(QtInfoMsg, {}, QStringLiteral("this must not crash"));
QCOMPARE(logger.filePath(), dir.filePath(QStringLiteral("blocker")) + QStringLiteral("/couchplay.log"));
QVERIFY(!QFile::exists(logger.filePath()));
}

void TestLogging::testAppendToExisting()
{
// A pre-existing log file's size is accounted for, so rotation can still
// trigger on a session that resumes a near-full file.
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString path = dir.path() + QStringLiteral("/couchplay.log");

// Seed a 150-byte file (just under the 200-byte cap).
{
QFile seed(path);
QVERIFY(seed.open(QIODevice::WriteOnly));
seed.write(QByteArray(150, 'x'));
}

RotatingFileLogger logger(path, 200, 3);
QVERIFY(logger.open());

// One modest write should push it over the cap and trigger a rotation.
logger.write(QtInfoMsg, {}, QStringLiteral("triggering rotation on top of an existing near-full log file"));

QVERIFY(QFile::exists(path));
QVERIFY(QFile::exists(path + QStringLiteral(".1")));
}

void TestLogging::testMaxBackupsOne()
{
// The documented contract lower bound: exactly one backup. Many rotations must
// never produce a .2; each rotation replaces the single .1 in place.
QTemporaryDir dir;
QVERIFY(dir.isValid());
const QString path = dir.path() + QStringLiteral("/couchplay.log");

RotatingFileLogger logger(path, 200, 1);
QVERIFY(logger.open());

for (int i = 0; i < 50; ++i) {
logger.write(QtInfoMsg, {}, QStringLiteral("line %1 padding to force many rotations").arg(i));
}

QVERIFY(QFile::exists(path));
QVERIFY(QFile::exists(path + QStringLiteral(".1")));
QVERIFY(!QFile::exists(path + QStringLiteral(".2")));
}

QTEST_MAIN(TestLogging)
#include "test_logging.moc"
Loading