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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ find_package(Qt6 6.5 REQUIRED COMPONENTS
QuickControls2
Widgets
DBus
Network
)

# Find KDE Frameworks 6
Expand Down
6 changes: 6 additions & 0 deletions appiumtests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,9 @@ def test_reset_dialog_opens(self, driver):
self.click_by_name(driver, "Reset to Defaults")
dialog = self.wait_for_element(driver, AppiumBy.NAME, "Reset Settings")
assert dialog.is_displayed()

def test_update_section_visible(self, driver):
self.navigate_to_settings(driver)
self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "labelUpdateStatus")
self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "btnCheckUpdates")
self.wait_for_element(driver, AppiumBy.ACCESSIBILITY_ID, "checkUpdateAutomatically")
3 changes: 3 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ target_link_libraries(couchplay_core PUBLIC
Qt6::QuickControls2
Qt6::Widgets
Qt6::DBus
Qt6::Network
KF6::I18n
KF6::CoreAddons
KF6::ConfigCore
Expand Down Expand Up @@ -100,6 +101,8 @@ target_sources(couchplay PRIVATE
core/SunshineConfig.h
core/StreamManager.cpp
core/StreamManager.h
core/UpdateChecker.cpp
core/UpdateChecker.h
dbus/CouchPlayHelperClient.cpp
dbus/CouchPlayHelperClient.h
)
Expand Down
15 changes: 15 additions & 0 deletions src/core/SettingsManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ void SettingsManager::loadSettings()
m_hidePanels = general.readEntry(QStringLiteral("HidePanels"), true);
m_killSteam = general.readEntry(QStringLiteral("KillSteam"), true);
m_restoreSession = general.readEntry(QStringLiteral("RestoreSession"), false);
m_checkForUpdatesAutomatically = general.readEntry(QStringLiteral("CheckForUpdatesAutomatically"), true);
m_ignoredDevices = general.readEntry(QStringLiteral("IgnoredDevices"), QStringList());

// Gamescope settings
Expand All @@ -43,6 +44,7 @@ void SettingsManager::saveAllSettings()
general.writeEntry(QStringLiteral("HidePanels"), m_hidePanels);
general.writeEntry(QStringLiteral("KillSteam"), m_killSteam);
general.writeEntry(QStringLiteral("RestoreSession"), m_restoreSession);
general.writeEntry(QStringLiteral("CheckForUpdatesAutomatically"), m_checkForUpdatesAutomatically);
general.writeEntry(QStringLiteral("IgnoredDevices"), m_ignoredDevices);

// Gamescope settings
Expand Down Expand Up @@ -87,6 +89,17 @@ void SettingsManager::setRestoreSession(bool value)
}
}

void SettingsManager::setCheckForUpdatesAutomatically(bool value)
{
if (m_checkForUpdatesAutomatically != value) {
m_checkForUpdatesAutomatically = value;
KSharedConfig::Ptr config = KSharedConfig::openConfig(QStringLiteral("couchplayrc"));
config->group(QStringLiteral("General")).writeEntry(QStringLiteral("CheckForUpdatesAutomatically"), m_checkForUpdatesAutomatically);
config->sync();
Q_EMIT checkForUpdatesAutomaticallyChanged();
}
}

void SettingsManager::setScalingMode(const QString &value)
{
if (m_scalingMode != value) {
Expand Down Expand Up @@ -158,6 +171,7 @@ void SettingsManager::resetToDefaults()
m_hidePanels = true;
m_killSteam = true;
m_restoreSession = false;
m_checkForUpdatesAutomatically = true;
m_scalingMode = QStringLiteral("fit");
m_filterMode = QStringLiteral("linear");
m_borderlessWindows = true;
Expand All @@ -168,6 +182,7 @@ void SettingsManager::resetToDefaults()
Q_EMIT hidePanelsChanged();
Q_EMIT killSteamChanged();
Q_EMIT restoreSessionChanged();
Q_EMIT checkForUpdatesAutomaticallyChanged();
Q_EMIT scalingModeChanged();
Q_EMIT filterModeChanged();
Q_EMIT borderlessWindowsChanged();
Expand Down
9 changes: 9 additions & 0 deletions src/core/SettingsManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class SettingsManager : public QObject
Q_PROPERTY(bool hidePanels READ hidePanels WRITE setHidePanels NOTIFY hidePanelsChanged)
Q_PROPERTY(bool killSteam READ killSteam WRITE setKillSteam NOTIFY killSteamChanged)
Q_PROPERTY(bool restoreSession READ restoreSession WRITE setRestoreSession NOTIFY restoreSessionChanged)
Q_PROPERTY(bool checkForUpdatesAutomatically READ checkForUpdatesAutomatically WRITE setCheckForUpdatesAutomatically NOTIFY checkForUpdatesAutomaticallyChanged)

// Gamescope settings
Q_PROPERTY(QString scalingMode READ scalingMode WRITE setScalingMode NOTIFY scalingModeChanged)
Expand Down Expand Up @@ -55,6 +56,12 @@ class SettingsManager : public QObject
}
void setRestoreSession(bool value);

bool checkForUpdatesAutomatically() const
{
return m_checkForUpdatesAutomatically;
}
void setCheckForUpdatesAutomatically(bool value);

// Gamescope settings
QString scalingMode() const
{
Expand Down Expand Up @@ -92,6 +99,7 @@ class SettingsManager : public QObject
void hidePanelsChanged();
void killSteamChanged();
void restoreSessionChanged();
void checkForUpdatesAutomaticallyChanged();
void scalingModeChanged();
void filterModeChanged();
void borderlessWindowsChanged();
Expand All @@ -105,6 +113,7 @@ class SettingsManager : public QObject
bool m_hidePanels = true;
bool m_killSteam = true;
bool m_restoreSession = false;
bool m_checkForUpdatesAutomatically = true;

// Gamescope settings
QString m_scalingMode = QStringLiteral("fit");
Expand Down
204 changes: 204 additions & 0 deletions src/core/UpdateChecker.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 CouchPlay Contributors

#include "UpdateChecker.h"

#include <QDebug>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringList>
#include <QUrl>

#include "couchplay-version.h"

UpdateChecker::UpdateChecker(QObject *parent)
: QObject(parent)
, m_nam(new QNetworkAccessManager(this))
{
m_installMethod = detectInstallMethod();
}

void UpdateChecker::checkForUpdates()
{
startCheck(false);
}

void UpdateChecker::checkForUpdatesOnStartup()
{
startCheck(true);
}

void UpdateChecker::startCheck(bool automatic)
{
if (m_state == Checking) {
return;
}

m_automatic = automatic;
setState(Checking);

QNetworkRequest request(QUrl(QStringLiteral("https://api.github.com/repos/hikaps/couchplay/releases/latest")));
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("couchplay/%1").arg(currentVersion()));
request.setRawHeader(QByteArrayLiteral("Accept"), QByteArrayLiteral("application/vnd.github+json"));
request.setTransferTimeout(10000);

QNetworkReply *reply = m_nam->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();

if (reply->error() != QNetworkReply::NoError) {
qWarning() << "UpdateChecker: request failed:" << reply->errorString();
setState(Error);
return;
}

const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (statusCode != 200) {
qWarning() << "UpdateChecker: unexpected HTTP status:" << statusCode;
setState(Error);
return;
}

const QString tag = parseLatestTag(reply->readAll());
if (tag.isEmpty()) {
qWarning() << "UpdateChecker: could not parse latest release response";
setState(Error);
return;
}

applyLatestTag(tag);
Q_EMIT checkFinished(m_automatic, m_updateAvailable, m_latestVersion);
});
}

void UpdateChecker::applyLatestTag(const QString &tag)
{
const QString normalized = normalizeVersion(tag);
if (m_latestVersion != normalized) {
m_latestVersion = normalized;
Q_EMIT latestVersionChanged();
}

const bool available = compareVersions(m_latestVersion, currentVersion()) > 0;
if (m_updateAvailable != available) {
m_updateAvailable = available;
Q_EMIT updateAvailableChanged();
}

setState(available ? UpdateAvailable : UpToDate);
qDebug() << "UpdateChecker:" << (available ? "update available:" : "up to date") << m_latestVersion;
}

QString UpdateChecker::normalizeVersion(const QString &version)
{
QString normalized = version.trimmed();
if (normalized.startsWith(QLatin1Char('v')) || normalized.startsWith(QLatin1Char('V'))) {
normalized.remove(0, 1);
}
return normalized;
}

int UpdateChecker::compareVersions(const QString &a, const QString &b)
{
const QStringList aParts = normalizeVersion(a).split(QLatin1Char('.'));
const QStringList bParts = normalizeVersion(b).split(QLatin1Char('.'));

const int maxParts = qMax(aParts.size(), bParts.size());
for (int i = 0; i < maxParts; ++i) {
const QString aPart = i < aParts.size() ? aParts.at(i) : QStringLiteral("0");
const QString bPart = i < bParts.size() ? bParts.at(i) : QStringLiteral("0");

bool aOk = false;
bool bOk = false;
const qlonglong aNum = aPart.toLongLong(&aOk);
const qlonglong bNum = bPart.toLongLong(&bOk);

int cmp = 0;
if (aOk && bOk) {
cmp = (aNum < bNum) ? -1 : (aNum > bNum) ? 1 : 0;
} else {
cmp = QString::compare(aPart, bPart, Qt::CaseInsensitive);
}
if (cmp != 0) {
return cmp;
}
}
return 0;
}

QString UpdateChecker::parseLatestTag(const QByteArray &json)
{
const QJsonDocument doc = QJsonDocument::fromJson(json);
if (!doc.isObject()) {
return QString();
}
const QJsonValue tag = doc.object().value(QStringLiteral("tag_name"));
if (!tag.isString()) {
return QString();
}
return tag.toString();
}

QString UpdateChecker::detectInstallMethod()
{
if (QFile::exists(QStringLiteral("/.flatpak-info"))) {
return QStringLiteral("flatpak");
}

QFile osRelease(QStringLiteral("/etc/os-release"));
if (osRelease.open(QIODevice::ReadOnly | QIODevice::Text)) {
while (!osRelease.atEnd()) {
const QString line = QString::fromUtf8(osRelease.readLine()).trimmed();
if (!line.startsWith(QStringLiteral("ID="))) {
continue;
}
QString value = line.mid(3);
if (value.startsWith(QLatin1Char('"')) && value.endsWith(QLatin1Char('"'))) {
value = value.mid(1, value.size() - 2);
}
if (value == QLatin1String("steamos")) {
return QStringLiteral("steamos");
}
}
}

return QStringLiteral("native");
}

UpdateChecker::State UpdateChecker::state() const
{
return m_state;
}

QString UpdateChecker::currentVersion() const
{
return QStringLiteral(COUCHPLAY_VERSION_STRING);
}

QString UpdateChecker::latestVersion() const
{
return m_latestVersion;
}

bool UpdateChecker::updateAvailable() const
{
return m_updateAvailable;
}

QString UpdateChecker::installMethod() const
{
return m_installMethod;
}

void UpdateChecker::setState(State state)
{
if (m_state == state) {
return;
}
m_state = state;
Q_EMIT stateChanged();
}
62 changes: 62 additions & 0 deletions src/core/UpdateChecker.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 CouchPlay Contributors

#pragma once

#include <QObject>
#include <qqmlintegration.h>
#include <QByteArray>
#include <QString>

class QNetworkAccessManager;

class UpdateChecker : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(State state READ state NOTIFY stateChanged)
Q_PROPERTY(QString currentVersion READ currentVersion CONSTANT)
Q_PROPERTY(QString latestVersion READ latestVersion NOTIFY latestVersionChanged)
Q_PROPERTY(bool updateAvailable READ updateAvailable NOTIFY updateAvailableChanged)
Q_PROPERTY(QString installMethod READ installMethod CONSTANT)
Q_PROPERTY(QString releasesUrl READ releasesUrl CONSTANT)
public:
enum State { Idle, Checking, UpToDate, UpdateAvailable, Error }; // plain enum: Q_ENUM/QML-safe
Q_ENUM(State)

explicit UpdateChecker(QObject *parent = nullptr);

Q_INVOKABLE void checkForUpdates(); // manual — no passive notification
Q_INVOKABLE void checkForUpdatesOnStartup(); // automatic — Main.qml only

// Pure helpers, public for unit tests:
static QString normalizeVersion(const QString &version);
static int compareVersions(const QString &a, const QString &b);
static QString parseLatestTag(const QByteArray &json);
static QString detectInstallMethod();

void applyLatestTag(const QString &tag); // public for unit tests; called by reply handler

State state() const;
QString currentVersion() const;
QString latestVersion() const;
bool updateAvailable() const;
QString installMethod() const;
QString releasesUrl() const { return QStringLiteral("https://github.com/hikaps/couchplay/releases"); }

Q_SIGNALS:
void stateChanged();
void latestVersionChanged();
void updateAvailableChanged();
void checkFinished(bool automatic, bool updateAvailable, const QString &latestVersion);

private:
void startCheck(bool automatic);
void setState(State state);
QNetworkAccessManager *m_nam = nullptr;
State m_state = Idle;
QString m_latestVersion;
bool m_updateAvailable = false;
bool m_automatic = false;
QString m_installMethod;
};
Loading
Loading