diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root new file mode 120000 index 0000000..945c9b4 --- /dev/null +++ b/_codeql_detected_source_root @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/kwin/CMakeLists.txt b/kwin/CMakeLists.txt index b2bd26f..bf0f5a0 100644 --- a/kwin/CMakeLists.txt +++ b/kwin/CMakeLists.txt @@ -35,7 +35,7 @@ include(cmake/info.cmake) find_package(epoxy REQUIRED) find_package(XCB REQUIRED COMPONENTS XCB) find_package(KWinDBusInterface CONFIG REQUIRED) -find_package(Qt6 REQUIRED COMPONENTS Core) +find_package(Qt6 REQUIRED COMPONENTS Core Network) # Qt6 sets QT6_INSTALL_QML which is distro-aware get_target_property(QT6_QMAKE_EXECUTABLE Qt6::qmake IMPORTED_LOCATION) diff --git a/kwin/src/kcm/CMakeLists.txt b/kwin/src/kcm/CMakeLists.txt index b9cd832..fd667bd 100644 --- a/kwin/src/kcm/CMakeLists.txt +++ b/kwin/src/kcm/CMakeLists.txt @@ -15,6 +15,7 @@ kcoreaddons_add_plugin(breezy_desktop_config INSTALL_NAMESPACE "plasma/kcms" SOU kconfig_add_kcfg_files(breezy_desktop_config ../breezydesktopconfig.kcfgc) target_link_libraries(breezy_desktop_config Qt6::DBus + Qt6::Network KF6::ConfigCore KF6::ConfigGui KF6::ConfigWidgets diff --git a/kwin/src/kcm/breezydesktopeffectkcm.cpp b/kwin/src/kcm/breezydesktopeffectkcm.cpp index 4124664..eac10aa 100644 --- a/kwin/src/kcm/breezydesktopeffectkcm.cpp +++ b/kwin/src/kcm/breezydesktopeffectkcm.cpp @@ -51,6 +51,8 @@ #include #include #include +#include +#include #include #include @@ -237,6 +239,9 @@ BreezyDesktopEffectConfig::BreezyDesktopEffectConfig(QObject *parent, const KPlu // One-time check if the KWin effect backend is actually loaded. If not, disable UI early. checkEffectLoaded(); + // Asynchronously check GitHub for a newer release. + checkForUpdates(); + // Show/enable Virtual Display controls only when we're on Wayland const bool isWaylandSession = QGuiApplication::platformName().contains(QStringLiteral("wayland"), Qt::CaseInsensitive) || qEnvironmentVariable("XDG_SESSION_TYPE").compare(QStringLiteral("wayland"), Qt::CaseInsensitive) == 0; @@ -588,6 +593,64 @@ void BreezyDesktopEffectConfig::checkEffectLoaded() { } } +void BreezyDesktopEffectConfig::checkForUpdates() { +#ifdef BREEZY_DESKTOP_VERSION_STR + if (!m_networkManager) + m_networkManager = new QNetworkAccessManager(this); + + QNetworkRequest request(QUrl(QStringLiteral("https://api.github.com/repos/wheaney/breezy-desktop/releases/latest"))); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("breezy-desktop-kcm")); + auto *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + qCDebug(KWIN_XR) << "Update check failed:" << reply->errorString(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + if (!doc.isObject()) return; + const QString latestTag = doc.object().value(QStringLiteral("tag_name")).toString(); + if (latestTag.isEmpty()) return; + + QString latest = latestTag; + if (latest.startsWith(QLatin1Char('v'))) latest.remove(0, 1); + + // Compare version tuples + const QString current = QLatin1String(BREEZY_DESKTOP_VERSION_STR); + auto parseParts = [](const QString &v) -> QList { + QList parts; + for (const QString &p : v.split(QLatin1Char('.'))) { + bool ok; + int n = p.toInt(&ok); + if (!ok) return {}; + parts.append(n); + } + return parts; + }; + const QList latestParts = parseParts(latest); + const QList currentParts = parseParts(current); + if (latestParts.isEmpty() || currentParts.isEmpty()) return; + bool isNewer = false; + for (int i = 0; i < qMax(latestParts.size(), currentParts.size()); ++i) { + int lv = i < latestParts.size() ? latestParts[i] : 0; + int cv = i < currentParts.size() ? currentParts[i] : 0; + if (lv != cv) { + isNewer = lv > cv; + break; + } + } + + if (isNewer) { + if (auto label = widget()->findChild(QStringLiteral("labelUpdateAvailable"))) { + label->setText(tr("A newer version (%1) is available").arg(latest)); + label->setVisible(true); + } + } + }); +#endif +} + static QDBusInterface makeVDInterface() { return QDBusInterface( QStringLiteral("org.kde.KWin"), diff --git a/kwin/src/kcm/breezydesktopeffectkcm.h b/kwin/src/kcm/breezydesktopeffectkcm.h index 69ee642..c0a5c80 100644 --- a/kwin/src/kcm/breezydesktopeffectkcm.h +++ b/kwin/src/kcm/breezydesktopeffectkcm.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -56,6 +57,7 @@ private: void pollDriverState(); void refreshLicenseUi(const QJsonObject &rootObj); void checkEffectLoaded(); + void checkForUpdates(); void showStatus(QLabel *label, bool success, const QString &message); void setRequestInProgress(std::initializer_list widgets, bool inProgress); bool eventFilter(QObject *watched, QEvent *event) override; @@ -71,6 +73,7 @@ private: ::Ui::BreezyDesktopEffectConfig ui; KConfigWatcher::Ptr m_configWatcher; + QNetworkAccessManager *m_networkManager = nullptr; bool m_updatingFromConfig = false; bool m_driverStateInitialized = false; bool m_deviceConnected = false; diff --git a/kwin/src/kcm/breezydesktopeffectkcm.ui b/kwin/src/kcm/breezydesktopeffectkcm.ui index 2ea3bae..a562324 100644 --- a/kwin/src/kcm/breezydesktopeffectkcm.ui +++ b/kwin/src/kcm/breezydesktopeffectkcm.ui @@ -39,6 +39,28 @@ + + + + + + + false + + + true + + + Qt::AlignHCenter|Qt::AlignVCenter + + + color: rgb(0,100,200); font-weight: bold; + + + true + + + diff --git a/ui/src/gtk/window.ui b/ui/src/gtk/window.ui index 044f36f..d59ac1f 100644 --- a/ui/src/gtk/window.ui +++ b/ui/src/gtk/window.ui @@ -79,6 +79,31 @@ + + + 0 + False + info + + + True + + + True + A newer version is available + True + + + + + + + View release + True + + + + diff --git a/ui/src/main.py b/ui/src/main.py index 720abe6..95428ef 100644 --- a/ui/src/main.py +++ b/ui/src/main.py @@ -90,7 +90,7 @@ class BreezydesktopApplication(Adw.Application): """ win = self.props.active_window if not win: - win = BreezydesktopWindow(self._skip_verification, application=self) + win = BreezydesktopWindow(self.version, self._skip_verification, application=self) win.connect('close-request', lambda *_: self.on_quit_action()) win.connect('destroy', lambda *_: self.on_quit_action()) win.present() diff --git a/ui/src/meson.build b/ui/src/meson.build index 22b16d3..95187b7 100644 --- a/ui/src/meson.build +++ b/ui/src/meson.build @@ -61,6 +61,7 @@ breezydesktop_sources = [ 'shortcutdialog.py', 'statemanager.py', 'time.py', + 'updatechecker.py', 'virtualdisplay.py', 'virtualdisplaymanager.py', 'verify.py', diff --git a/ui/src/updatechecker.py b/ui/src/updatechecker.py new file mode 100644 index 0000000..3f6a755 --- /dev/null +++ b/ui/src/updatechecker.py @@ -0,0 +1,64 @@ +# updatechecker.py +# +# Copyright 2024 Unknown +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import logging +import threading +from urllib.request import urlopen, Request +from urllib.error import URLError + +logger = logging.getLogger('breezy_ui') + +GITHUB_RELEASES_URL = 'https://api.github.com/repos/wheaney/breezy-desktop/releases/latest' +GITHUB_RELEASES_PAGE = 'https://github.com/wheaney/breezy-desktop/releases/latest' + + +def _parse_version(version_str): + """Parse a version string like '2.8.10' or 'v2.8.9' into a tuple of ints.""" + v = version_str.strip().lstrip('v') + try: + return tuple(int(x) for x in v.split('.')) + except (ValueError, AttributeError): + return None + + +def check_for_update(current_version, callback): + """ + Asynchronously check for a newer version on GitHub. + + Calls callback(latest_version_str) on the calling thread's GLib main loop + if a newer version is found, or callback(None) if no update is available + or if the check fails. + """ + def _check(): + latest_version = None + try: + req = Request(GITHUB_RELEASES_URL, headers={'User-Agent': 'breezy-desktop-ui'}) + with urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode()) + latest_tag = data.get('tag_name', '') + latest = _parse_version(latest_tag) + current = _parse_version(current_version) + if latest and current and latest > current: + latest_version = latest_tag.lstrip('v') + except (URLError, json.JSONDecodeError, ValueError, OSError) as e: + logger.debug('Update check failed: %s', e) + callback(latest_version) + + threading.Thread(target=_check, daemon=True).start() diff --git a/ui/src/window.py b/ui/src/window.py index e537222..2e031af 100644 --- a/ui/src/window.py +++ b/ui/src/window.py @@ -29,6 +29,7 @@ from .nodevice import NoDevice from .nodriver import NoDriver from .noextension import NoExtension from .nolicense import NoLicense +from .updatechecker import check_for_update, GITHUB_RELEASES_PAGE from .verify import verify_installation @Gtk.Template(resource_path='/com/xronlinux/BreezyDesktop/gtk/window.ui') @@ -40,8 +41,10 @@ class BreezydesktopWindow(Gtk.ApplicationWindow): license_action_needed_button = Gtk.Template.Child() missing_breezy_features_banner = Gtk.Template.Child() missing_breezy_features_button = Gtk.Template.Child() + update_available_banner = Gtk.Template.Child() + update_available_button = Gtk.Template.Child() - def __init__(self, skip_verification, **kwargs): + def __init__(self, version, skip_verification, **kwargs): super().__init__(**kwargs) self.connected_device = ConnectedDevice() @@ -63,6 +66,7 @@ class BreezydesktopWindow(Gtk.ApplicationWindow): self.license_action_needed_button.connect('clicked', self._on_license_button_clicked) self.missing_breezy_features_button.connect('clicked', self._on_license_button_clicked) + self.update_available_button.connect('clicked', self._on_update_button_clicked) self._handle_state_update(self.state_manager, None) @@ -70,6 +74,8 @@ class BreezydesktopWindow(Gtk.ApplicationWindow): self.connect("destroy", self._on_window_destroy) + check_for_update(version, self._on_update_check_result) + def _handle_settings_update(self, settings_manager, key): self._handle_state_update(self.state_manager, None) @@ -112,5 +118,11 @@ class BreezydesktopWindow(Gtk.ApplicationWindow): dialog.set_transient_for(widget.get_ancestor(Gtk.Window)) dialog.present() + def _on_update_button_clicked(self, widget): + Gtk.show_uri(self, GITHUB_RELEASES_PAGE, 0) + + def _on_update_check_result(self, latest_version): + GLib.idle_add(self.update_available_banner.set_revealed, latest_version is not None) + def _on_window_destroy(self, widget): self.state_manager.disconnect_by_func(self._handle_state_update) \ No newline at end of file