Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packaging/com.tinkernorth.Dish.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
<!-- Kept in step with project(Dish VERSION ...); version-consistency.yml
fails the PR that moves one without the other. -->
<releases>
<release version="0.1.2" date="2026-08-24" type="stable">
<url type="details">https://github.com/TinkerNorth/dish-linux/releases/tag/0.1.2</url>
<description>
<p>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.</p>
</description>
</release>
<release version="0.1.1" date="2026-08-23" type="stable">
<url type="details">https://github.com/TinkerNorth/dish-linux/releases/tag/0.1.1</url>
<description>
Expand Down
13 changes: 13 additions & 0 deletions resources/tray.qrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!--
The StatusNotifier tray icon, pre-rasterised from packaging/dish.svg at the
two sizes the item publishes over IconPixmap. Compiled into dish_core (not
the exe) so the tray never depends on an installed icon theme, the Qt SVG
plugin, or runtime painting; regenerate with Qt's QSvgRenderer if the brand
mark changes.
-->
<RCC>
<qresource prefix="/tray">
<file alias="dish-22.png">tray/dish-22.png</file>
<file alias="dish-48.png">tray/dish-48.png</file>
</qresource>
</RCC>
Binary file added resources/tray/dish-22.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added resources/tray/dish-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions src/source/tray/SniIcon.cpp
Original file line number Diff line number Diff line change
@@ -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 <QLatin1String>
#include <QStringLiteral>

#include <cstdint>

// 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<void>(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<qsizetype>(pixmap.width) * static_cast<qsizetype>(pixmap.height) * 4;
pixmap.data = QByteArray(byteCount, '\0');
auto* out = reinterpret_cast<std::uint8_t*>(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<std::uint32_t>(image.pixel(x, y)));
offset += 4;
}
}
return pixmap;
}

} // namespace dish::source
19 changes: 19 additions & 0 deletions src/source/tray/SniIcon.h
Original file line number Diff line number Diff line change
@@ -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 <QImage>
#include <QString>

namespace dish::source {

inline constexpr auto kSniIconName = "com.tinkernorth.Dish";

QString sniIconName(bool themeHasIcon);
SniIconPixmapList sniTrayPixmaps();
SniIconPixmap toSniPixmap(const QImage& source);

} // namespace dish::source
91 changes: 9 additions & 82 deletions src/source/tray/StatusNotifierTrayIcon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@

#include "source/tray/StatusNotifierTrayIcon.h"

#include "Util/Endian.h"
#include "source/tray/SniIcon.h"

#include <QColor>
#include <QCoreApplication>
#include <QDBusAbstractAdaptor>
#include <QDBusConnectionInterface>
Expand All @@ -18,17 +17,9 @@
#include <QDBusVariant>
#include <QGuiApplication>
#include <QIcon>
#include <QImage>
#include <QLoggingCategory>
#include <QPainter>
#include <QPen>
#include <QPointF>
#include <QRectF>
#include <QSize>
#include <QStringLiteral>

#include <cstdint>

namespace dish::source {

namespace {
Expand All @@ -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";

Expand Down Expand Up @@ -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<qsizetype>(pixmap.width) * static_cast<qsizetype>(pixmap.height) * 4;
pixmap.data = QByteArray(byteCount, '\0');
auto* out = reinterpret_cast<std::uint8_t*>(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<std::uint32_t>(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<qreal>(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<qreal>(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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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_; }

Expand All @@ -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:
Expand Down Expand Up @@ -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); }
Expand Down
1 change: 1 addition & 0 deletions src/source/tray/StatusNotifierTrayIcon.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions tests/test_tray_icon_fallback.cpp
Original file line number Diff line number Diff line change
@@ -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 <catch2/catch_test_macros.hpp>

#include <QImage>
#include <QLatin1String>

#include <cstdint>

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<qsizetype>(pixmap.width) * static_cast<qsizetype>(pixmap.height) * 4);
bool anyInk = false;
bool anyTransparent = false;
for (qsizetype offset = 0; offset < pixmap.data.size(); offset += 4) {
const auto alpha = static_cast<std::uint8_t>(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<const std::uint8_t*>(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"));
}
Loading