diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8b6eb..daf36ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ the repos share a version number. ## [Unreleased] +## [0.1.2] - 2026-08-24 + +### Fixed + +- The tray item no longer advertises the `com.tinkernorth.Dish` icon name when + the icon theme cannot resolve it. StatusNotifier hosts prefer `IconName` over + `IconPixmap`, so a run without the hicolor icons installed (a source build, + the AppImage) rendered GNOME's "..." missing-icon placeholder in the top bar + even though pixmaps were shipped. The name is now advertised only when the + theme lookup succeeds, `IconPixmap` carries the brand mark pre-rasterised at + 22 and 48 px and compiled into the client (replacing the hand-drawn + stand-in glyph), and the tooltip carries the same icon fields. + ## [0.1.1] - 2026-08-23 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index 94ac22d..5283bc9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.21) project(Dish - VERSION 0.1.1 + VERSION 0.1.2 DESCRIPTION "Dish Linux client for the Satellite gamepad-over-LAN protocol" LANGUAGES CXX) @@ -306,8 +306,13 @@ set(DISH_CORE_SOURCES src/source/system/SleepMonitor.cpp src/source/system/WakeInhibitor.cpp src/source/tray/TrayIcon.h + src/source/tray/SniIcon.h + src/source/tray/SniIcon.cpp src/source/tray/StatusNotifierTrayIcon.h src/source/tray/StatusNotifierTrayIcon.cpp + # The tray's IconPixmap PNGs ride dish_core so DishTests and the exe both + # carry them; SniIcon.cpp's Q_INIT_RESOURCE keeps the archive member alive. + resources/tray.qrc # Update checking. In dish_core (not the exe) because the tests link this # library, not the exe. src/update/UpdatePorts.h diff --git a/packaging/com.tinkernorth.Dish.metainfo.xml b/packaging/com.tinkernorth.Dish.metainfo.xml index 9dbb3dc..9d95179 100644 --- a/packaging/com.tinkernorth.Dish.metainfo.xml +++ b/packaging/com.tinkernorth.Dish.metainfo.xml @@ -102,6 +102,12 @@ + + https://github.com/TinkerNorth/dish-linux/releases/tag/0.1.2 + +

Fixes the tray icon showing as a missing-icon placeholder when Dish's launcher icons are not installed in the icon theme, as with the AppImage or a source build: the tray now ships the brand mark inside the client and advertises a theme icon name only where the theme can resolve it.

+
+
https://github.com/TinkerNorth/dish-linux/releases/tag/0.1.1 diff --git a/resources/tray.qrc b/resources/tray.qrc new file mode 100644 index 0000000..c2574eb --- /dev/null +++ b/resources/tray.qrc @@ -0,0 +1,13 @@ + + + + tray/dish-22.png + tray/dish-48.png + + diff --git a/resources/tray/dish-22.png b/resources/tray/dish-22.png new file mode 100644 index 0000000..38c205c Binary files /dev/null and b/resources/tray/dish-22.png differ diff --git a/resources/tray/dish-48.png b/resources/tray/dish-48.png new file mode 100644 index 0000000..318d4bd Binary files /dev/null and b/resources/tray/dish-48.png differ diff --git a/src/source/tray/SniIcon.cpp b/src/source/tray/SniIcon.cpp new file mode 100644 index 0000000..451f8cd --- /dev/null +++ b/src/source/tray/SniIcon.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/tray/SniIcon.h" + +#include "Util/Endian.h" + +#include +#include + +#include + +// Outside the namespace, as Q_INIT_RESOURCE requires. dish_core is a static +// library, so nothing else forces the linker to keep the resource object. +static void initTrayResource() { + static const bool sInitialised = [] { + Q_INIT_RESOURCE(tray); + return true; + }(); + static_cast(sInitialised); +} + +namespace dish::source { + +QString sniIconName(bool themeHasIcon) { + return themeHasIcon ? QLatin1String(kSniIconName) : QLatin1String(""); +} + +SniIconPixmapList sniTrayPixmaps() { + initTrayResource(); + SniIconPixmapList pixmaps; + for (const int size : {22, 48}) { + const QImage image(QStringLiteral(":/tray/dish-%1.png").arg(size)); + if (image.isNull()) { continue; } + pixmaps.append(toSniPixmap(image)); + } + return pixmaps; +} + +SniIconPixmap toSniPixmap(const QImage& source) { + const QImage image = source.convertToFormat(QImage::Format_ARGB32); + SniIconPixmap pixmap; + pixmap.width = image.width(); + pixmap.height = image.height(); + const qsizetype byteCount = + static_cast(pixmap.width) * static_cast(pixmap.height) * 4; + pixmap.data = QByteArray(byteCount, '\0'); + auto* out = reinterpret_cast(pixmap.data.data()); + qsizetype offset = 0; + for (int y = 0; y < pixmap.height; ++y) { + for (int x = 0; x < pixmap.width; ++x) { + util::putU32Be(out + offset, static_cast(image.pixel(x, y))); + offset += 4; + } + } + return pixmap; +} + +} // namespace dish::source diff --git a/src/source/tray/SniIcon.h b/src/source/tray/SniIcon.h new file mode 100644 index 0000000..6e5cfba --- /dev/null +++ b/src/source/tray/SniIcon.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#pragma once + +#include "source/tray/StatusNotifierTrayIcon.h" + +#include +#include + +namespace dish::source { + +inline constexpr auto kSniIconName = "com.tinkernorth.Dish"; + +QString sniIconName(bool themeHasIcon); +SniIconPixmapList sniTrayPixmaps(); +SniIconPixmap toSniPixmap(const QImage& source); + +} // namespace dish::source diff --git a/src/source/tray/StatusNotifierTrayIcon.cpp b/src/source/tray/StatusNotifierTrayIcon.cpp index 38159c7..12abb3e 100644 --- a/src/source/tray/StatusNotifierTrayIcon.cpp +++ b/src/source/tray/StatusNotifierTrayIcon.cpp @@ -3,9 +3,8 @@ #include "source/tray/StatusNotifierTrayIcon.h" -#include "Util/Endian.h" +#include "source/tray/SniIcon.h" -#include #include #include #include @@ -18,17 +17,9 @@ #include #include #include -#include #include -#include -#include -#include -#include -#include #include -#include - namespace dish::source { namespace { @@ -40,7 +31,6 @@ constexpr auto kWatcherPath = "/StatusNotifierWatcher"; constexpr auto kItemInterface = "org.kde.StatusNotifierItem"; constexpr auto kItemPath = "/StatusNotifierItem"; constexpr auto kMenuPath = "/MenuBar"; -constexpr auto kIconName = "com.tinkernorth.Dish"; constexpr auto kNewIconSignal = "NewIcon"; constexpr auto kNewToolTipSignal = "NewToolTip"; @@ -87,73 +77,6 @@ QVariantMap filterProperties(const QVariantMap& properties, const QStringList& n return filtered; } -// a(iiay) carries raw 32-bit ARGB in network byte order, so the source has to -// be unpremultiplied ARGB32 and every pixel goes out through putU32Be — -// casting the scan line to a uint32_t* would raise alignment and trip -// -Wcast-align. -SniIconPixmap toSniPixmap(const QImage& source) { - const QImage image = source.convertToFormat(QImage::Format_ARGB32); - SniIconPixmap pixmap; - pixmap.width = image.width(); - pixmap.height = image.height(); - const qsizetype byteCount = - static_cast(pixmap.width) * static_cast(pixmap.height) * 4; - pixmap.data = QByteArray(byteCount, '\0'); - auto* out = reinterpret_cast(pixmap.data.data()); - qsizetype offset = 0; - for (int y = 0; y < pixmap.height; ++y) { - for (int x = 0; x < pixmap.width; ++x) { - util::putU32Be(out + offset, static_cast(image.pixel(x, y))); - offset += 4; - } - } - return pixmap; -} - -// :/icons/dish.svg belongs to the exe's resource bundle, not to dish_core, so -// the fallback is drawn rather than loaded. -QImage renderFallbackIcon(int size) { - QImage image(size, size, QImage::Format_ARGB32); - image.fill(Qt::transparent); - const auto extent = static_cast(size); - const QPointF origin(extent * 0.24, extent * 0.78); - const QColor ink(0xF5, 0xF6, 0xF8); - - QPainter painter(&image); - painter.setRenderHint(QPainter::Antialiasing, true); - QPen pen(ink); - pen.setWidthF(qMax(1.0, extent * 0.11)); - pen.setCapStyle(Qt::RoundCap); - painter.setPen(pen); - painter.setBrush(Qt::NoBrush); - for (int ring = 1; ring <= 3; ++ring) { - const qreal radius = extent * 0.18 * static_cast(ring); - painter.drawArc( - QRectF(origin.x() - radius, origin.y() - radius, radius * 2.0, radius * 2.0), 5 * 16, - 80 * 16); - } - painter.setPen(Qt::NoPen); - painter.setBrush(ink); - painter.drawEllipse(origin, extent * 0.09, extent * 0.09); - painter.end(); - return image; -} - -// Theme lookup misses entirely under Flatpak, Snap and AppImage, so IconPixmap -// always ships alongside IconName instead of only when the theme fails. -SniIconPixmapList buildIconPixmaps() { - const QIcon themed = QIcon::fromTheme(QLatin1String(kIconName)); - SniIconPixmapList pixmaps; - for (const int size : {22, 48}) { - QImage image; - if (!themed.isNull()) { image = themed.pixmap(QSize(size, size)).toImage(); } - if (image.isNull()) { image = renderFallbackIcon(size); } - if (image.isNull()) { continue; } - pixmaps.append(toSniPixmap(image)); - } - return pixmaps; -} - } // namespace // Returning the const reference parameter is the signature QtDBus REQUIRES of a @@ -258,7 +181,7 @@ class StatusNotifierItemAdaptor final : public QDBusAbstractAdaptor { : QDBusAbstractAdaptor(host), owner_(owner) {} QString category() const { return QStringLiteral("ApplicationStatus"); } - QString id() const { return QLatin1String(kIconName); } + QString id() const { return QLatin1String(kSniIconName); } QString title() const { return QStringLiteral("Dish"); } // Pinned Active: a Passive item may be hidden by the host, and a hidden // item is a running Dish the user can neither reach nor quit. @@ -350,7 +273,7 @@ StatusNotifierTrayIcon::~StatusNotifierTrayIcon() { teardown(); } bool StatusNotifierTrayIcon::isAvailable() const { return available_; } -QString StatusNotifierTrayIcon::iconName() const { return QLatin1String(kIconName); } +QString StatusNotifierTrayIcon::iconName() const { return sniIconName(themeHasIcon_); } SniIconPixmapList StatusNotifierTrayIcon::iconPixmap() const { return iconPixmap_; } @@ -360,7 +283,8 @@ uint StatusNotifierTrayIcon::menuRevision() const { return 1; } SniToolTip StatusNotifierTrayIcon::toolTip() const { SniToolTip tip; - tip.iconName = QLatin1String(kIconName); + tip.iconName = sniIconName(themeHasIcon_); + tip.iconPixmap = iconPixmap_; tip.title = QStringLiteral("Dish"); switch (presentation_.activity) { case reducer::TrayActivity::Idle: @@ -547,8 +471,11 @@ void StatusNotifierTrayIcon::refreshAvailability() { emit availabilityChanged(available_); } +// Theme lookup misses entirely under Flatpak, Snap and AppImage, so IconPixmap +// always ships alongside IconName instead of only when the theme fails. void StatusNotifierTrayIcon::updateIcon() { - SniIconPixmapList pixmaps = buildIconPixmaps(); + themeHasIcon_ = QIcon::hasThemeIcon(QLatin1String(kSniIconName)); + SniIconPixmapList pixmaps = sniTrayPixmaps(); if (pixmaps == iconPixmap_) { return; } iconPixmap_ = pixmaps; if (exported_) { emitItemSignal(kNewIconSignal); } diff --git a/src/source/tray/StatusNotifierTrayIcon.h b/src/source/tray/StatusNotifierTrayIcon.h index d73eb54..73d1e3f 100644 --- a/src/source/tray/StatusNotifierTrayIcon.h +++ b/src/source/tray/StatusNotifierTrayIcon.h @@ -119,6 +119,7 @@ class StatusNotifierTrayIcon final : public TrayIcon { QObject* menuHost_ = nullptr; reducer::TrayPresentation presentation_; SniIconPixmapList iconPixmap_; + bool themeHasIcon_ = false; bool shown_ = false; bool exported_ = false; bool registered_ = false; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 837e564..226ac14 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -93,6 +93,7 @@ set(DISH_TEST_SOURCES test_tray_presentation.cpp test_tray_composer.cpp test_tray_controller.cpp + test_tray_icon_fallback.cpp test_sleep_cycle.cpp test_sleep_coordinator.cpp test_dish_notifications.cpp diff --git a/tests/test_tray_icon_fallback.cpp b/tests/test_tray_icon_fallback.cpp new file mode 100644 index 0000000..11004cb --- /dev/null +++ b/tests/test_tray_icon_fallback.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/tray/SniIcon.h" +#include "source/tray/StatusNotifierTrayIcon.h" + +#include + +#include +#include + +#include + +using dish::source::kSniIconName; +using dish::source::sniIconName; +using dish::source::sniTrayPixmaps; +using dish::source::StatusNotifierTrayIcon; +using dish::source::toSniPixmap; + +TEST_CASE("SniIcon: an unresolvable theme icon advertises no IconName", "[tray]") { + REQUIRE(sniIconName(false).isEmpty()); +} + +TEST_CASE("SniIcon: a resolvable theme icon advertises the reverse-DNS name", "[tray]") { + REQUIRE(sniIconName(true) == QLatin1String(kSniIconName)); +} + +TEST_CASE("SniIcon: the bundled pixmaps decode at the exact panel sizes", "[tray]") { + const auto pixmaps = sniTrayPixmaps(); + REQUIRE(pixmaps.size() == 2); + const int sizes[] = {22, 48}; + for (int i = 0; i < 2; ++i) { + const auto& pixmap = pixmaps.at(i); + REQUIRE(pixmap.width == sizes[i]); + REQUIRE(pixmap.height == sizes[i]); + REQUIRE(pixmap.data.size() == + static_cast(pixmap.width) * static_cast(pixmap.height) * 4); + bool anyInk = false; + bool anyTransparent = false; + for (qsizetype offset = 0; offset < pixmap.data.size(); offset += 4) { + const auto alpha = static_cast(pixmap.data.at(offset)); + if (alpha > 0) { anyInk = true; } + if (alpha == 0) { anyTransparent = true; } + } + REQUIRE(anyInk); + REQUIRE(anyTransparent); + } +} + +TEST_CASE("SniIcon: pixmap bytes are ARGB in network byte order", "[tray]") { + QImage image(2, 1, QImage::Format_ARGB32); + image.setPixel(0, 0, 0x80402010U); + image.setPixel(1, 0, 0x01020304U); + + const auto pixmap = toSniPixmap(image); + REQUIRE(pixmap.width == 2); + REQUIRE(pixmap.height == 1); + REQUIRE(pixmap.data.size() == 8); + + const auto* bytes = reinterpret_cast(pixmap.data.constData()); + REQUIRE(bytes[0] == 0x80); + REQUIRE(bytes[1] == 0x40); + REQUIRE(bytes[2] == 0x20); + REQUIRE(bytes[3] == 0x10); + REQUIRE(bytes[4] == 0x01); + REQUIRE(bytes[5] == 0x02); + REQUIRE(bytes[6] == 0x03); + REQUIRE(bytes[7] == 0x04); +} + +TEST_CASE("StatusNotifierTrayIcon: no IconName before the theme is probed", "[tray]") { + StatusNotifierTrayIcon icon; + REQUIRE(icon.iconName().isEmpty()); +} + +TEST_CASE("StatusNotifierTrayIcon: the tooltip mirrors the item's icon fields", "[tray]") { + StatusNotifierTrayIcon icon; + const auto tip = icon.toolTip(); + REQUIRE(tip.iconName == icon.iconName()); + REQUIRE(tip.iconPixmap == icon.iconPixmap()); + REQUIRE(tip.title == QStringLiteral("Dish")); +}